DESIGN.md: Sentin8:Auth — Detailed Design Document
1. Scope & Context
Sentin8:Auth is the platform's single identity plane: the only issuer of every token family, the only JWKS authority, the DCR registrar, the RBAC substrate (roles, groups, nesting, membership provisioning API), consent grants, sessions, MFA/passkeys, SSO federation, webhooks, and the OID4VCI/SD-JWT wallet surface.
- Owns (schema
auth): identity + credentials (users, sessions, app passwords, MFA factors/challenges, WebAuthn challenges, reset/verify tokens, enterprise connections, SSO sessions), OAuth clients (incl. DCR records, federated credentials, M2M thumbprints), M2M scope grants, RBAC (roles, user_roles, groups, group_members, group_roles, role_nesting), consent grants (FAPI 2.0), wallet credentials + regulatory overlays + security profiles, actor tokens, webhook endpoints/outbox/attempts, signing keys, and the DBX producer tables (outbox,audit_journal,post_outbox). - Does NOT own: org/env business records (sys provisions those and
calls back via the provisioning API), tenant app instances, Shield
policy storage (sys's
policy_setsis the substrate; auth runs an engine on it), invitations (proxied to sys), e-signature (QES delegated to bank8-payments —signature_requestreturns NOT IMPLEMENTED).
2. Runtime Architecture
Request lifecycle — authorization-code token issuance (FAPI 2.0 posture, platform profile):
M2M issuance (cert-bound, the platform identity path):
3. Data Model
- RLS: FORCE RLS on all tenant tables (env policies; junctions carry
their own
env_idfor direct RLS;role_nestinggated on the parent role's env). No-RLS exceptions (platform-internal):m2m_dcr_credentials(auth's own workloads only),m2m_scope_grants(anonymous DCR endpoint evaluates server-side),webhook_attempts(relay-written), andactor_revocations(USING (true)— revocation is jti-scoped, not env-scoped). - State machines:
- Secrets posture: TOTP secrets, webhook secrets, client secrets are
encrypted (
secret_cipherBYTEA / hash); signing keys persisted AES-sealed under the master key; no plaintext secrets in any table. - Seeds: platform admin user + the platform role/nesting catalog +
dash(public PKCE, dpop_exempt) +sentin8-cli(device flow) clients.
4. API Surface
| Audience | Group | Gate |
|---|---|---|
| public | /.well-known/*, /oauth/authorize(+continue), /oauth/par, /oauth/token, /oauth/device+/device/verify, /oauth/userinfo, /oauth/logout, /v1/sign-in|up|out, /v1/branding, /v1/health | session/cookie or client auth per flow; bot protection + lockout on sign-in |
| mTLS | /mtls/oauth/token, /mtls/oauth/revoke | cert-presence guard; client auth per DCR method — retained for out-of-band/tenant clients only; platform workloads never fetch tokens (§4.3f) |
| workload | POST /oauth/register (DCR) | interactive clients open; client_credentials-only requires mTLS cert + CN ∈ m2m_scope_grants + scopes ⊆ grant — retained for out-of-band tenant clients; platform workloads never DCR (§4.3f) |
| delegated | POST /delegate/access-token | verified M2M bearer + sentin8:delegate:read + cnf match |
| wallet | /oauth/credentials*, /oauth/vp/response, /v1/auth/wallet/metadata | bearer / VP verified (SD-JWT key binding + status list) |
| consent | /v1/auth/consent/* | session-validated; proxied to owning vertical |
| admin | /v1/admin/{users,roles,groups,oauth-clients,api-keys,sessions,memberships,invitations,audit} + RFW UI | route_guard + require_sentin8or_access(_write) + Shield |
| provisioning | POST /v1/admin/provision/{role,role_nesting,group,group_role,group_member,user_role} | admin bearer + x-sentin8-target-env-id header (env-scoped), idempotent upserts |
| SCIM | /scim/v2/Users, /scim/v2/Groups, metadata | sentin8:scim:read/write |
| security | /v1/security-profile, /v1/admin/security | admin bearer |
| webhooks | /v1/admin/webhooks/{endpoints,outbox,attempts} | Shield |
| health | /v1/health | none |
Event flow (outbox → Bus):
Event family auth; names sentin8:auth:{user,role,group,client,credential,apikey,session,membership,dcr,connection,mfa,passkey,actortoken,securityprofile,webhook,grant,ciba}:{action} — payload-complete envelopes. Session revocations
(sign-out, OAuth refresh-token revoke, password reset, admin terminate) emit
sentin8:auth:session:revoked — payload-complete envelope {subject_id, env_id, session_id, revoked_by, reason} (continuous access evaluation,
README §7): the BFF session-reaper subscription (bff DESIGN §10) consumes the
auth family (group bff-session-reaper) and kills every cached BFF session
for the subject, so role/consent changes take effect immediately instead of at
the store TTL.
Grant lifecycle (FAPI 2.0 Grant Management, grants.rs): every standing
authorization emits its transition. Code issuance records/merges the grant
(record_grant at the authorization-code exchange) and emits
sentin8:auth:grant:created — payload {grant_id, client_id, user_id, env_id, scope, state, selected_account_ids} where state is the client's
opaque OAuth state (the consent handle a vertical's consent flow packed into
PAR) and selected_account_ids are the accounts the customer selected on a
hosted consent screen. Downstream consent authorities (e.g. Bank8:FDX's
provider plane, fdx DESIGN §1) seed their data-holder grant ledgers from this
event — the authorization server is the seed of open-banking grant truth.
Grant revocation emits sentin8:auth:grant:revoked
({grant_id, user_id, client_id}).
5. Cross-Cutting Concerns
- Token verification is SDK-reused by every service
(
sentin8::auth::client) — auth is the only JWKS authority. - Rate limiting / bot protection: IP 10/min, email 5/min sliding
windows; 5 failures → durable
lockedstatus (config TTL); dummy bcrypt compare on unknown accounts (no email enumeration). - DPoP: proof validation (htm/htu/iat/nonce/ath) with jti replay
protection (SETNX 300s);
cnf.jkton access tokens; clients opt out viadpop_exempt. - FAPI 2.0 posture: platform profile enforces PAR + JARM + DPoP +
RFC 9449 issuer binding + 900s max token lifetime + refresh rotation;
env/org profiles override (
security_profiles). - Introspection honesty:
none-auth clients hard-rejected; JWT-introspection responses signed per profile. - Back-channel logout: logout token issued to registered
backchannel_logout_uriper client.
4.3f Token-free platform M2M (CERTFREE-1) — the certificate is the service identity
Platform workloads authenticate with no token at all: the caller presents
its Acme-issued client certificate (CN=m2m-{workload}) in the TLS
handshake, sentin8-tls CA-verifies it and injects
X-Sentin8-Client-Cert-Subject / -Thumbprint headers (client-supplied
copies stripped), and the receiving service trusts those headers exactly as
it trusts X-Sentin8-Env-Id — the TLS layer is the authentication. Auth's
role in platform M2M shrinks to issuing identity material (the
certificate, via Acme) and provisioning the authorization record (the
grant rows, via vertical enroll). No M2M JWT is minted, fetched,
cached, or verified for platform service→service calls.
Why (the defect class this closes): the M2M token was a second identity
surface that had to stay synchronized with the certificate identity across
three stores (SDK credential cache → oauth_clients → JWT claims). A
database reset desynchronized them by construction — cached client_ids
outlived their rows — producing the fleet-wide
M2M token fetch failed: status 401 storm after every reset. A token is
also a redundant proof: the TLS handshake ALREADY establishes exclusive
possession of the certificate's private key on every call; a bearer minted
from that same possession adds no strength, only state.
The authorization record travels as the registry map. sentin8-cli vertical enroll already provisions auth.m2m_scope_grants and publishes
captain:* Valkey keys; it additionally publishes a m2m:workloads hash —
{workload} → {vertical, service_type, scopes[], enabled} — and every
service boot-loads it and TTL-caches it (the platform's mandated pattern:
owner data is seeded on cold start / cache miss, never read on the request
path — §4.14/§6.6). This answers the three consumers the token previously
fed:
| Consumer | Was (token claim) | Now (map field) |
|---|---|---|
Shield RequestCtx (scope-gated rules) | scope | scopes (roles: none — M2M workloads never had role assignments) |
| Bus VDC-2 event-ownership gate | vertical | vertical (produce + consume identity checks) |
Audit service stamp / Sys tenant-snapshot | sub (opaque hex) / service_type | CN directly / service_type |
Revocation is the map's enabled flag: vertical enroll (or the admin
surface) flips it, every service's cache observes it within the TTL, and the
workload's calls are refused. This is more immediate than the token path
(a minted token lives to expiry regardless) and is bounded by the cache
TTL. Handshake-level certificate revocation (a stolen cert's validity)
remains a follow-up (CERTFREE-2) — a pre-existing gap, not a regression.
Security posture vs the token design (2026-10-07 review): the move is a
net improvement — per-call proof-of-possession (the token was a pure bearer
at the target; verify_cert_binding was a stub) and fleet-wide revocation
where none existed for M2M JWTs. The multi-tenant boundary does not move:
tenancy was never carried by machine identity (WARN-21: the request's env
is authoritative for M2M), and RLS + explicit env_id clauses + Shield
remain the tenant gates. Two new risks are ACCEPTED-AND-FILED:
- The map's write surface is authorization escalation. Any process
with Valkey write access can
HSET m2m:workloadsitself any scope set, and the 30s TTL propagates it fleet-wide — a primitive the signature-verified token design denied (scopes were minted from a DB row Auth signed). Countermeasure: Valkey ACLs (CERTFREE-4) — m2m:* writes restricted to the operator/enroll writer identity; services hold a read-only reader identity. - Certificate lifetime bounds the theft window. The 365-day default mint + no expiry check in the load path (a day-366 fleet brick) is tightened to a 30-day default with load-path NotAfter verification and auto re-mint (CERTFREE-5), alongside CERTFREE-2's CRL.
- Per-instance distinguishability collapses to the workload name (two
instances share
{workload}.crt) — accepted; audit identifies workloads, never instances.
Invariants (CERTFREE-1):
- The certificate is the only platform-workload credential: no secret, no
bearer, no assertion. The
m2m-CN prefix is minted exclusively by Acme's guarded issuance channels (bootstrap enrollment — pinned to the declared set by CERTFREE-3; self-issuancesub == service_name; the enroll operator scope). - The TLS-injected headers are trustworthy because (a)
sentin8-tlsinjects them only after CA verification, (b) client-supplied copies are stripped before the app sees them, and (c) they exist only on TLS-terminated platform hops — the same trust modelX-Sentin8-Env-Idalready carries. - A workload can only ever exercise scopes its map entry allows (Shield evaluates from the map; policy rules are unchanged).
oauth_clientsand DCR survive for interactive clients (auth-code, device flow, BFF apps, dash) and out-of-band tenant clients (client_secret_basic/private_key_jwtper registration) — Auth remains the single identity plane for every HUMAN-facing flow; the token-free plane covers machine-to-machine platform calls only.- Rejected: keeping the token as "extra" (it was the storm's root
cause and adds no proof beyond the handshake); services reading
auth.m2m_scope_grantsdirectly (§4.7 — the map is the transport, no cross-schema grants); a live Auth resolve API per request (per-request dependency, banned by the runtime discipline).
What is deleted for the platform path: /mtls/oauth/token
client_credentials use by platform workloads; the sentin8-m2m token
source, DCR helper, credential cache, boot probe, and 401 self-heal
machinery (the crate shrinks to certificate mint-if-missing, renamed for
its real job); build_with_dcr background DCR retries in bus-client; every
service's boot-time DCR block; the cli-producer / cli-delegator /
bff-delegator token workloads (the CLI and BFF present certificates);
BFF's env-credential fallback. Delegated tokens keep their cnf
cert-binding (unchanged) — BFF presents its certificate and receives a
delegated JWT as today; only the workload's own M2M JWT disappears.
6. Failure Modes & Recovery
- JWKS rotation: insert-new-key overlap (all persisted keys published); no scheduled rotation task — old tokens verify until expiry.
- Session expiry: enforced by predicates; no sweeper (rows are harmless after expiry).
- DCR storm protection: anonymous DCR gated by
m2m_scope_grants(service must be enrolled) + cert channel possession proof. Platform workloads never DCR and never fetch tokens (§4.3f) — a DB reset can no longer strand cachedclient_ids; a missing grant row surfaces as a named workload refusal (workload not enrolled) from the registry map, fixed by enrollment. - Token-free M2M failure modes (§4.3f): certificate missing at boot → Acme mint (bootstrap channel, declared-set-pinned) → retry with backoff; map row missing/disabled → 401/403 until enroll/revocation is reverted (the map is the authorization record); Valkey down at boot → services start with the last cache file / an empty map and refuse unenrolled workloads fail-closed (enrolled workloads ride the persisted cache copy); map edit → next TTL refresh reflects it.
7. Concurrency & Performance Model
- Sign-in hot path: 2 PG round-trips + 3 Valkey ops (rate, lockout, session); JWKS cached 5 min in every consumer SDK.
- Token minting: one signing op (ML-DSA-65); key material cached in process.
- Webhook relay batches 25 per tick to bound endpoint fan-out.
- Post pusher identity is process-lifetime. The
auth-postTokenSourceis resolved once and reused across passes (audit-pusher pattern,crates/audit§6.8): the cached token covers ~10 passes before the 60s-before-expiry refresh, and a 401 triggers the m2m crate's single-flight self-heal (re-issue + re-DCR, 60s cooldown) — never a per-passregister_or_load_m2m_clientload + credential probe + fresh mTLS client. A per-pass rebuild would run two PQ TLS handshakes + two token POSTs every 10s whose transient allocations ratchet glibc arenas (observed idle RSS growth ~2 MB/min, AGENTS target-state rule: no transitional churn paths).
7.1 Invalidation triggers on access-grant mutations (policy family)
Identity-cache invalidation (policy runtime's recognized triggers:
sentin8:sys:identity:updated, sentin8:auth:membership:created|deleted
— sentin8-policy runtime.rs invalidation_env_id) rides the SYSTEM-env
policy stream because policy consumers (every service's M2M workload)
are registered in the SYSTEM env — that is the transport, not a policy
choice. The shell outbox relay holds exactly one M2M workload
(SYSTEM-env, sentin8:bus:produce), and Bus's produce gate
(env_id does not match the authenticated tenant) rejects any row whose
env_id differs from the token's env: tenant-env outbox rows can
never reach the policy stream. Therefore every mutation that grants
or revokes access emits, in the SAME transaction as the domain change,
a SYSTEM-env row (SENTIN8_SYSTEM_ENV_ID, family policy, event
sentin8:auth:membership:created/sentin8:auth:membership:deleted,
payload {"env_id": <affected tenant env>, "user_id": <subject>}) —
env to sweep rides the payload (the tenant-env fan-out row for domain
consumers stays, on the auth family). Covered mutations: sign-up
closure provisioning (auto_add_to_app_groups, one invalidation per
env touched), admin group member add/remove, and the cross-service
provisioning endpoint provision_group_member. Emission is
best-effort: an RLS/failure drop logs and degrades to the RoleResolver
30s TTL. The tenant context on the emitting transaction is switched to
SYSTEM for the invalidation INSERT (set_config is transaction-local)
and restored for any subsequent tenant-plane statement — policies on
auth.outbox match env_id = current, so the row must be written
under the env it carries.
8. Invariants, Insecure-Defaults Rejected, Unsafe Rationale
- Single identity plane — no other service mints tokens/JWKS; the
provisioning API is the only cross-service write path to
auth.*(cross-schema INSERTs retired). - M2M is certificate-bound by default — a stolen token without the
key is useless; DCR for
client_credentialsrequires the mTLS cert channel (no dev fallback). scopeis not the decision — handlers never re-check scopes; step 5 resolves live identity.- Refresh rotation + jti denylist — replay within TTL is detectable; fail-open only on cache outage (expiry predicates backstop).
- No existence oracle — uniform
invalid_client; dummy bcrypt on unknown accounts. - Tenant-scoped uniqueness — emails/roles/groups unique per
(env_id, …); sessions carrytoken_idUNIQUE. - Actor tokens are time-boxed and audited — TTL clamp ≤ 3600s,
auth.actor_events+ jti revocation table. - Keys persisted + encrypted; wrong/rotated master key refuses boot.
- No
unsafein the service; manual ML-DSA-65 signing wrapped in the crypto provider boundary. - No per-pass identity churn. Long-lived background pushers bind
their M2M
TokenSourcefor the process lifetime; identities are re-established by the token-source self-heal on 401, not by rebuilding clients every pass (DESIGN §7). - Invalidation rows carry SYSTEM env. An access-grant mutation's
invalidation row is written under the SYSTEM tenant context with
env_id = SENTIN8_SYSTEM_ENV_ID; a tenant-env row is silently dropped by RLS at emit time (policyoutbox_insert:env_id = current) and denied at the Bus produce gate — both fail closed (DESIGN §7.1). - Auth reads
sys.app_instancesonly through Sentin8:App's owner API. The sign-up closure provisioning needs the env's customer-mode (allow_signup) instance ids: the former direct-table read violated DBX §6.6a (auth held no grant onsys.app_instancesafter the sys.* grant retirement — the read failed RLS-grantless and "provisioned NO permission closure" on every customer-env sign-up). Target state:auto_add_to_app_groupscalls Sentin8:AppGET /v1/app/instances/customer-mode?env_id=…(M2M workloadauth-sys, scopesentin8:app:instances:read, env-bound) — the owner never shares tables (DESIGN §7.1).
9. Testing Strategy
- Deliberately not covered in-service: OpenID certification live flows (external conformance suites), real IdP SSO round-trips, PQ TLS transport (platform-level).
10. Open Questions
- SAML flows beyond metadata/ACS are a declared non-goal for the platform IdP (enterprise SSO is served by OIDC federation).
- Status List keeps the per-credential bit (standard model: verifiers fetch the credential status); fleet-wide aggregation is out of scope.
- Webhook delivery gains a DLQ replay surface mirroring post's
replay_dlqshape (POST /v1/admin/webhooks/attempts/{id}/replay). - Push-based identity invalidation is already implemented as events
(
sentin8:sys:identity:updated); role resolvers subscribe instead of relying only on TTL polling.
JWKS rotation is internal: a daily in-process timer (03:00, 30-day staleness gate) calls the in-process rotate path directly — auth links no scheduler client and makes no cross-service calls (SDD §2.5, root service rule).
Design attribution: sentin8:auth and its underlying concepts were designed by Hamid Jahanzad Hamid@Jahanzad.com.