From 4d3de6c58c93cb0054591a463bd375c2b397edfd Mon Sep 17 00:00:00 2001 From: fred Date: Wed, 26 Aug 2026 17:49:24 -0500 Subject: [PATCH] docs: identity account-lifecycle contract (S2 contract 4, D10/Q1=O1; folds #1430 bootstrap invariant) --- docs/requirements/identity-lifecycle.md | 146 ++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/requirements/identity-lifecycle.md diff --git a/docs/requirements/identity-lifecycle.md b/docs/requirements/identity-lifecycle.md new file mode 100644 index 00000000..3ce62e96 --- /dev/null +++ b/docs/requirements/identity-lifecycle.md @@ -0,0 +1,146 @@ +# Identity Account-Lifecycle Contract + +Status: DRAFT — awaiting ratification (webui-audit S2, contract 4 of 9). +Authority: PRD D10 (better-auth is the account system of record), Q1 ruled O1 +by Jason 2026-08-26 (webui-audit T10). This document turns that ruling into +enforceable policy. It also carries the bootstrap/first-admin invariant from +issue #1430, folded in here after PR #1431's independent review showed the +quick-fix approach was insufficient. + +Scope: account creation, bootstrap, federated login, account linking, claim +mapping, and deactivation. Out of scope: RBAC grant semantics (contract 2), +wizard UX flow (contract 3), hierarchy schema (contract 1). + +## 1. System of record + +1. better-auth's tables (`users`, `accounts`, `sessions`, `verifications`) are + the only account system of record. All foreign keys reference `users.id`. +2. External IdPs (Authentik or any OIDC provider) are login methods, attached + through better-auth's generic-OAuth plugin (`packages/auth/src/sso.ts`). + They never own accounts. Removing an IdP removes a login method, not users. +3. The forward-auth perimeter shim is a deployment measure. Once in-app OIDC + is configured for a deployment, the shim is demoted: it may stay as network + perimeter, but no application code may read identity from its headers. + +## 2. Registration gating + +Measured current state on `next`: `emailAndPassword.enabled: true` with no +gating — anyone who can reach the Gateway can create an account via +`POST /api/auth/sign-up/email` and receives role `member`. + +Contract: + +1. A single server-side setting `registration_mode` with values + `open | invite | closed`. It lives in the database (admin-mutable at + runtime), not in env config. +2. Default after bootstrap: `closed`. The wizard (contract 3) may set it + during setup; Enterprise mode defaults to `closed` with OIDC JIT enabled. +3. `closed` blocks self-service email/password sign-up. It does not block + admin-created users or OIDC JIT (§4), each gated separately. +4. `invite` requires a single-use, expiring invite token bound to an email + address. Invite issuance is an admin operation and is audit-logged. +5. Enforcement point: a better-auth hook (or equivalent middleware executed + inside the auth handler path), not a Gateway route guard in front of it — + the raw `/api/auth/*` handler must be incapable of bypassing the gate. + +## 3. Bootstrap / first-admin invariant (from #1430) + +Invariant: **the system transitions from zero users to one admin user exactly +once per bootstrap epoch, atomically, regardless of concurrency or which code +path writes users.** + +Constraints any implementation MUST satisfy (each traces to a verified defect +in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`): + +1. **All-writer coverage or database enforcement.** The invariant must hold + against every user-creating path: bootstrap setup, better-auth sign-up, + OIDC JIT, and admin creation. An advisory lock taken only in the bootstrap + controller does not satisfy this; a concurrent sign-up can commit between + the locked count and the admin insert. Acceptable mechanisms: a partial + unique index / constraint-backed one-row `bootstrap_state` table whose + transition insert races safely at the database level, or registration_mode + enforcement (§2) that provably closes every non-bootstrap writer while the + epoch is open. +2. **Pool safety.** No design may hold a pooled connection inside a + transaction while awaiting a write that acquires a second connection from + the same pool (`DB_POOL_MAX=1` is a supported configuration; better-auth's + `drizzleAdapter(db)` uses the root pool, not the caller's transaction). +3. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin + epoch completes, re-running the wizard reconfigures the system but never + re-opens the zero-user transition. "Setup already completed" is a stable, + testable state, and factory-reset (a future, explicitly destructive + operation) is the only way to open a new epoch. +4. **Atomic outcome.** The first-admin transition either yields admin user + + initial credential/token together or nothing observable. A failure may not + strand an admin user without its token such that setup can never retry + (the pre-existing failure mode noted in the #1431 review). + +## 4. JIT provisioning (OIDC first login) + +1. A successful OIDC login with no matching account creates a user + just-in-time only when `jit_provisioning` is enabled (per-provider flag, + default off). +2. JIT users receive role `member`, never an elevated role, regardless of IdP + claims (§5). +3. An optional per-provider email-domain allowlist constrains JIT. Empty + allowlist with JIT on means any authenticated subject at that IdP gets an + account — permitted, but the wizard must present it as an explicit choice. +4. JIT is disabled while the bootstrap epoch is open (§3.1). + +## 5. Claim mapping + +1. Mapped at first login and refreshed at each login: `email`, + `email_verified`, display name, avatar. +2. Never mapped from IdP claims: `role` and any future authorization + attribute. Authorization lives in the system of record and in the RBAC + layer (contract 2). An IdP group/role claim may at most be recorded for + audit; it grants nothing. +3. Email change at the IdP does not silently rebind the account (§6.3). + +## 6. Account linking trust + +1. Linking an OIDC identity to an existing account happens only in one of two + ways: (a) explicit link initiated by the logged-in user from settings, or + (b) automatic link when the IdP asserts a verified email exactly matching + an existing account **and** the provider is marked `trusted_for_linking` + (per-provider flag, default off). +2. Untrusted-provider email collision produces a login error naming the + conflict, not an auto-link and not a duplicate account. +3. A linked identity whose IdP email later diverges from the account email + keeps working (the link is by provider subject id, not email) but the + divergence is surfaced in the user's settings and audit log. +4. Unlinking the last login method that the user can actually use is refused + unless a password is set first. + +## 7. Deactivation propagation + +1. Deactivation (better-auth admin ban) is authoritative and immediate: all + sessions for the user are revoked at ban time, and banned users fail every + guard, including active websocket sessions, within one heartbeat interval. +2. Deactivation at an external IdP does not propagate automatically in this + contract's scope (no SCIM). Operational rule: removing a user from the IdP + without banning them in Mosaic leaves any password or other linked login + method usable — the admin guide must state this. SCIM/webhook-driven + propagation is future work and out of scope here. +3. Deletion is not deactivation. Account deletion semantics (FK fan-out + across the 21 referencing tables) are deferred to the custody contract + (contract 7 / D14); until ratified, deactivation is the only supported + removal operation. + +## 8. Verification requirements + +1. The bootstrap invariant (§3) requires a real-PostgreSQL two-connection + concurrency test (pattern: + `apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`, + runs in the `ci-postgres` CI step). Mocked-transaction specs are + supplementary; they cannot prove serialization. +2. Registration gating (§2) requires spec coverage of all three modes against + the raw `/api/auth/` handler path, not only against Gateway controllers. +3. Linking (§6) requires specs for: trusted auto-link, untrusted collision + error, and last-usable-method unlink refusal. + +## Ruling request + +Ratify sections 1–8 as written, with one decision embedded: registration +defaults to `closed` after bootstrap (§2.2) — say "agreed" or name the mode +you want as the default.