Identity and authentication
core-api owns identity end to end. There is no external identity provider: users, credentials, roles, sessions and subscriber sign-in codes all live in this service's Postgres database.
Data model
| Table | Purpose |
|---|---|
users | One row per login. id (UUID) is the JWT sub. email (lower-cased, unique), password_hash (argon2id, nullable until a password is set), password_set, names, active and sign-in telemetry - no role: access comes from explicit memberships. |
refresh_tokens | SHA-256 of each issued opaque refresh token, its family_id, expiry and revoked_at. |
login_codes | HMAC of each emailed subscriber sign-in code, expiry and consumed_at. |
memberships | Every access grant: a role at platform scope (superadmin) or tenant scope, plus account-scope restrictions for members. user_id is a users.id string (no FK). See Roles and scopes. |
mfa_challenges, mfa_recovery_codes, password_reset_tokens, email_change_tokens, rate_limit_hits, user_preferences, user_identities | Security and profile support tables, see Identity security and Users. |
Everything that stores a user reference (Membership.user_id, Ticket.reported_by, audit
actors, Customer.user_id, Account.user_id) holds the users.id value without a foreign key.
Tokens and sessions
- Access token: HS256 JWT signed with
JWT_SECRET, 15 minutes (ACCESS_TOKEN_TTL_MINUTES). Claims:sub,email,roles(always empty),iss,aud,iat,exp,jti- identity only. Roles are not in the token: they are resolved from memberships on every request. - Per-request check:
get_current_userverifies the signature and then loads theusersrow. The claims handed to routes are rebuilt from that row and the user's memberships, so a deactivated user is rejected on the very next request and a role change applies immediately rather than after the token expires. Claims also carrymfa(whether the user has MFA on). - Refresh token: opaque, 30 days (
REFRESH_TOKEN_TTL_DAYS), only its hash is stored. It rotates on every use; presenting an already-rotated token revokes the whole family (stolen-token detection). Logout, a password change, a role change and deactivation revoke refresh tokens. - Cookies: HttpOnly
app.at/app.rt,SameSite=Lax,Path=/.SecurefollowsCOOKIE_SECURE, defaulting to on whenDASHBOARD_URLis https.Authorization: Beareris also accepted; the CSRF origin check applies only to cookie-authenticated unsafe requests.
Roles
A user has no role of their own; every grant is an explicit membership with its own role -
superadmin at platform scope, and tenant_admin, operator, member or customer per tenant.
Nothing is implicit and nothing is inherited. See Roles and scopes.
Endpoints
POST /auth/login, /auth/mfa/verify, /auth/refresh, /auth/logout,
/auth/subscriber/request, /auth/subscriber/verify, /auth/password-reset/* and
GET /auth/providers are public (refresh and logout authenticate by the refresh cookie);
/auth/subscriber/set-password and /me need a session.
/users manages users (permission users:read / users:write), scoped to the caller's
tenant by membership (a platform role may list across tenants with all_tenants=true).
User invitations, lifecycle states, the directory, self profile and idempotency are described in Users, invites and lifecycle.
Subscriber sign-in codes
/auth/subscriber/request (needs X-Tenant-ID, no session) finds the customer by email/phone and
answers {mode: "otp"} for any miss (no account enumeration). For a customer whose email belongs
to an inactive login or one holding a non-customer grant it silently does nothing. Otherwise it
creates the user if needed, grants the tenant's customer role, mints a 10-character code (about
50 bits), emails it with a magic link ({DASHBOARD_URL}/login?otp=...), and enforces a 60 s resend
cooldown (502 if the email cannot be sent). /auth/subscriber/verify takes only the code (looked up by its HMAC), is
single-use, and starts a session. A customer with no password yet is told to create one
(must_set_password on /me); set-password only works while no password is set (409
otherwise). Once a password exists the request endpoint answers {mode: "password", email}.
See also Identity security: sessions, lockout, rate limits, MFA, password reset, audit events and the SSO extension point.
Configuration
| Variable | Notes |
|---|---|
JWT_SECRET | Required (>= 32 chars, not a placeholder) unless ENVIRONMENT=local, where a dev default is used. Also keys the sign-in-code HMAC. |
JWT_ISSUER, JWT_AUDIENCE | Defaults bssentinel-core-api, bssentinel. |
ACCESS_TOKEN_TTL_MINUTES, REFRESH_TOKEN_TTL_DAYS | 15 / 30. |
LOGIN_CODE_TTL_MINUTES, LOGIN_CODE_COOLDOWN_SECONDS | 10 / 60. |
COOKIE_SECURE | Overrides the https-derived default. |
DASHBOARD_URL | Used in magic links. |
Local dev logins are created by mise run db:reseed (scripts/seed_data.py):
[email protected] (superadmin), [email protected] (operator in every client tenant),
[email protected], [email protected] (member) and [email protected]
(customer) - the last three in the Celtic tenant - all with password password.
API keys
Machine credentials (Authorization: Bearer bss_live_...) are documented in
API keys and machine identities.
Notifications, escalation and tenant switch
See Notifications, escalation and tenant switch.