Skip to main content

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​

TablePurpose
usersOne 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_tokensSHA-256 of each issued opaque refresh token, its family_id, expiry and revoked_at.
login_codesHMAC of each emailed subscriber sign-in code, expiry and consumed_at.
membershipsEvery 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_identitiesSecurity 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_user verifies the signature and then loads the users row. 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 carry mfa (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=/. Secure follows COOKIE_SECURE, defaulting to on when DASHBOARD_URL is https. Authorization: Bearer is 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​

VariableNotes
JWT_SECRETRequired (>= 32 chars, not a placeholder) unless ENVIRONMENT=local, where a dev default is used. Also keys the sign-in-code HMAC.
JWT_ISSUER, JWT_AUDIENCEDefaults bssentinel-core-api, bssentinel.
ACCESS_TOKEN_TTL_MINUTES, REFRESH_TOKEN_TTL_DAYS15 / 30.
LOGIN_CODE_TTL_MINUTES, LOGIN_CODE_COOLDOWN_SECONDS10 / 60.
COOKIE_SECUREOverrides the https-derived default.
DASHBOARD_URLUsed 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.

Authorization​

See Authorization: permissions, roles and route gating.