diff --git a/docs/requirements/identity-lifecycle.md b/docs/requirements/identity-lifecycle.md new file mode 100644 index 00000000..e69b4a70 --- /dev/null +++ b/docs/requirements/identity-lifecycle.md @@ -0,0 +1,328 @@ +# 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. + +Revision 2: addresses the 9 findings of the independent review +(`fleet/lanes/webui-audit/findings/pr1433-review.md`) — epoch enforcement +tightened (§3), canonical email split from provider claims (§5), external +principal keyed by issuer+subject with DB uniqueness and link step-up (§6), +JIT default precedence and first-admin SSO path defined (§2, §4), +deactivation made measurable (§7.1), deletion kept in scope and the existing +hard-delete endpoint required to fail closed (§7.3), workspace identity +reconciled with the native-kanban SOT (§1.4), verification matrix expanded +(§8), factual labels corrected (§7.3, §8.1). + +Revision 3: addresses the residuals and new findings of the revision-2 +re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) — +`users.emailVerified` added to the canonical set with a defined reset rule on +email change (§5.1–5.2), external-principal uniqueness moved to +(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped +delete affordances (web admin page, `mosaic auth users delete`) required to +be removed or disabled with a defined user-visible state (§7.3), the +admin-creation switch removed in favor of plain admin authorization (§2.3), +and §8 extended with observables for IdP removal, forward-auth non-use, +first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and +positive/expiry-bound step-up cases. + +Scope: account creation, bootstrap, federated login, account linking, claim +mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC +grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema +(contract 1), sensitive-data custody (contract 7 / D14). + +## 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. +4. **Account ≠ workspace membership.** Creating an account (by any path: + bootstrap, sign-up, invite, JIT, admin creation) creates no workspace, no + hierarchy grant, and no workspace-scoped authority (native-kanban SOT + REQ-TEN-001 / REQ-ID-001). The better-auth `role` field is a platform/auth + role (`member` | `admin`), not workspace membership. Workspace grants are + defined by contract 2; until then a fresh account can authenticate and + holds no workspace authority. + +## 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 a + different mode during setup, recorded as an explicit operator choice. + While the bootstrap epoch is open (§3), the effective mode is `closed` + regardless of any stored value: the setting takes effect only after the + epoch completes. +3. `closed` blocks self-service email/password sign-up. It does not block + admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is + gated by admin authorization alone — there is no separate switch for it. + JIT is gated by its per-provider flag (§4.1). All user-creating paths are + closed while the bootstrap epoch is open (§3). +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. **Durable fail-closed epoch state, obeyed by every writer.** The epoch + lives in a constraint-backed one-row `bootstrap_state` table. While the + epoch is open, every non-bootstrap user-creating writer — better-auth + sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside + the writer's own path (better-auth hook for the raw handler; guard for + admin routes). A partial unique index or a winning epoch-transition row is + necessary but not sufficient on its own: neither stops an untagged insert + from a writer that never consulted the epoch. Both layers are required: + database-level transition safety (the epoch-completing write races safely + and at most one wins) and writer-level refusal (no path can create a user + without reading epoch state). +2. **Atomic first-admin transition.** The admin user, its credential account, + the initial admin token, and the epoch-completed transition commit in one + database transaction or not at all. A better-auth call through + `drizzleAdapter(db)` runs on the root pool and is NOT part of any caller + transaction; it may be used inside the bootstrap transition only if the + adapter is explicitly bound to the transaction handle. Otherwise the + bootstrap writer must create the user rows itself within the transaction. +3. **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). +4. **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. +5. **No stranded partial outcome.** A failure at any point in the transition + leaves nothing observable (no admin user without its token, no completed + epoch without an admin) and setup remains retryable — this follows from + §3.2 and is stated separately because it is the pre-existing failure mode + the #1431 review verified. +6. **First-admin via SSO (D4).** When the operator chooses SSO for the + initial user, the wizard executes the OIDC login as part of the bootstrap + transition itself: the bootstrap writer creates the account from the + asserted identity inside the §3.2 transaction. This path is the bootstrap + writer, not JIT — §4's JIT gate stays closed during the epoch and is not + an obstacle to D4. + +## 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 for that provider. + The flag is per-provider and defaults off, always. There is no + mode-implied default: Enterprise setup enables JIT only when the wizard + records it as an explicit operator choice for a named provider (this + replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT + enabled", which contradicted the per-provider default). +2. JIT users receive platform role `member`, never an elevated role, + regardless of IdP claims (§5), and no workspace authority (§1.4). +3. An optional per-provider email-domain allowlist constrains JIT. The + allowlist matches only when the IdP asserts the email with + `email_verified: true`; an unverified address never satisfies the + allowlist. 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). The first-admin + SSO path is §3.6, not JIT. + +## 5. Claim mapping + +1. **Two stores, not one.** Provider-observed claims (`email`, + `email_verified`, display name, avatar) are recorded per external + principal — keyed by issuer + subject (§6.1) — at first login and + refreshed at each login. The canonical account fields (`users.email`, + `users.emailVerified`, `users.name`, `users.image`) are set exactly once + at account creation and are never silently overwritten by a later login. + For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified` + is set from the provider's `email_verified` claim at creation; for + password-created accounts it is false until the address completes + verification. +2. **Canonical email changes only through an explicit workflow.** Either the + user-initiated email change (with verification of the new address) or an + admin edit. Any canonical email change — user- or admin-initiated — sets + `users.emailVerified` to false until the new address completes + verification; an admin may instead explicitly attest the address as + verified in the same operation, and that attestation is audit-logged. A + provider-claim refresh never rebinds `users.email` or + `users.emailVerified`; a divergence between canonical email and the latest + provider-observed email is surfaced per §6.4. +3. 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. + +## 6. Account linking trust + +1. **External principal identity is issuer + subject.** A linked identity is + keyed by the OIDC issuer and subject claims, not by an unqualified + provider subject id and not by email. The linked-identity row stores the + issuer, and the database enforces at most one local account per + **(issuer, subject)** with a unique constraint on those stored columns — + uniqueness on (provider, subject) is insufficient because provider → + issuer is not one-to-one: two provider configurations can point at the + same issuer, and the identity must not alias across them. The current + non-unique `(provider_id, account_id)` index satisfies neither; + application-level checks without a uniqueness witness lose + concurrent-callback races. Each configured provider additionally binds to + exactly one issuer, immutable after creation (changing the issuer means + creating a new provider). +2. 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, + which requires step-up: a fresh reauthentication (password or existing + linked method) no older than a short bound the implementation defines + (≤ 10 minutes) — a session cookie alone is insufficient, so a stolen + session cannot quietly attach a durable login method; 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). +3. Untrusted-provider email collision produces a login error naming the + conflict, not an auto-link and not a duplicate account. +4. A linked identity whose IdP-observed email later diverges from the + canonical account email keeps working (the link is by issuer + subject, + §6.1) but the divergence is surfaced in the user's settings and audit log + (the per-principal claim store in §5.1 is what makes the divergence + representable). +5. Unlinking a login method is refused when it would leave the account with + no **usable** login method. Usable means: a set password, or a linked + identity whose provider is currently configured and enabled on this + deployment. A linked identity whose provider has been removed or disabled + (§1.2) is not usable and does not count; setting a password first lifts + the refusal. + +## 7. Deactivation propagation + +1. **Deactivation (better-auth admin ban) is authoritative and bounded.** + Concretely: + - Ban and session revocation are one operation: the ban commit revokes all + better-auth sessions for the user. If revocation partially fails, the + ban itself must already be committed and every guard denies from that + point (fail closed); the operation is retryable. + - Every authenticated entry path checks banned state: HTTP session guards, + the admin bearer-token path (which today does not test `banned` — an + implementation defect this contract makes non-conformant), MCP, and + Socket.IO. + - Active socket connections are terminated or denied within 30 seconds of + the ban commit, or at the next inbound message on that socket, whichever + comes first (socket auth at connect-time only, as today, does not + satisfy this). + - The current admin ban route updates only the user row; it does not + conform to this section until revocation and guard coverage land. + - Admin tokens owned by the banned user are revoked in the same operation. +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, and deletion is gated here.** Account + deletion semantics (FK fan-out across the 21 foreign-key constraints to + `users.id`, spread over 19 referencing tables) require their own + deletion-and-retention contract, chartered as an addition to the S2 list — + contract 7 is the D14 sensitive-data custody contract and does not cover + account deletion. Until that deletion contract is ratified: the existing + hard-delete endpoint (`DELETE /api/admin/users/:id`) is disabled and fails + closed, and deactivation is the only supported removal operation. A + contract that merely declared deactivation "the only supported removal" + while the endpoint stayed live would be false on its face. + Disabling the endpoint alone is insufficient — its shipped callers must + not be left as advertised operations that now fail generically: + - The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx` + and any SPA port of it) is removed, or replaced by a disabled control + whose visible text states that deletion is unavailable pending the + deletion-and-retention contract and points at deactivation. + - The CLI command `mosaic auth users delete` + (`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero + with a message stating the same and naming the deactivation command. + - Both surfaces expose deactivation as the supported operation. + +## 8. Verification requirements + +Every MUST above needs a bounded observable. The matrix: + +1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two + distinct physical connections (pattern: + `apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`, + which runs in the `test` CI step against the `ci-postgres` PostgreSQL + service — note that pattern multiplexes one pooled handle, so the tests + here must explicitly open separate connections). Races to cover: + setup-vs-setup, setup-vs-raw-sign-up, setup-vs-JIT, setup-vs-admin-create. + Plus: liveness under `DB_POOL_MAX=1`; fault injection after each write in + the transition (user, credential, token, epoch) proving nothing observable + leaks and setup retries; wizard re-run after completion proving the + zero-user transition never re-opens. Mocked-transaction specs are + supplementary; they cannot prove serialization. +2. **Registration gating (§2).** Spec coverage of all three modes against the + raw `/api/auth/` handler path, not only Gateway controllers; invite + lifecycle (single-use, expiry, email binding); effective-`closed` while + the epoch is open regardless of stored mode. +3. **JIT (§4).** Provider flag off → no account on first OIDC login; on → + account with platform role `member` and no workspace grant; domain + allowlist rejects an unverified email claim even when the domain matches; + JIT refused while the epoch is open. +4. **Claim mapping (§5).** Login refresh updates the per-principal claim + store and touches none of the canonical fields (`users.email`, + `users.emailVerified`, name, image); explicit email-change workflow is the + only path that rebinds canonical email; every canonical email change + resets `users.emailVerified` to false unless the admin attestation path + is taken, and that attestation appears in the audit log. +5. **Linking (§6).** Unique-constraint witness: concurrent first-login + callbacks for the same (issuer, subject) yield exactly one account, and + two provider configurations sharing one issuer cannot create two accounts + for the same subject; trusted auto-link; untrusted collision error; + step-up both ways: an explicit link succeeds immediately after a fresh + reauthentication and is refused once the implementation's chosen bound + (≤ 10 minutes) has elapsed, and refused with no reauthentication at all; + unlink refusal when no remaining method is usable per §6.5, including the + removed-provider case, and acceptance after a password is set; divergence + surfaced after IdP email change. +6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed + (partial-failure injection); guard denial post-ban on each transport: + HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated + within the 30-second/next-message bound; banned user's admin tokens + unusable; hard-delete endpoint returns a fail-closed error while the + deletion contract is unratified; the admin web UI renders no live delete + action (absent, or disabled with the §7.3 text) and `mosaic auth users +delete` exits non-zero with the §7.3 message — both asserted by spec. +7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal: + deleting a provider configuration leaves every user row intact and every + other login method working (spec over the provider-config removal path). + Forward-auth non-use: with in-app OIDC configured, a request carrying + forward-auth identity headers and no session is treated as anonymous — + no code path derives identity from those headers (negative spec at the + Gateway entry). First-admin SSO: the §3.6 transition commits account, + token, and epoch atomically from the asserted identity, and fault + injection mid-transition leaves nothing observable (same harness as §8.1). + Wizard-recorded JIT choice: enabling JIT for a provider writes an + explicit per-provider operator-choice record, and no mode selection + enables it implicitly (assert the stored record, not UI behavior). +8. **Documentation observable (§7.2).** The admin guide contains the + IdP-removal-does-not-deactivate statement; verified by a docs assertion + (content check in CI or an enumerated review-checklist item on the + implementing PR) — a MUST about documentation needs a checkable artifact, + not intent. + +## 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.