From 4d3de6c58c93cb0054591a463bd375c2b397edfd Mon Sep 17 00:00:00 2001 From: fred Date: Wed, 26 Aug 2026 17:49:24 -0500 Subject: [PATCH 1/4] 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. -- 2.54.0 From f507b2bc79ca2a390a40a3a7f31ed3032f9e5b6d Mon Sep 17 00:00:00 2001 From: fred Date: Wed, 26 Aug 2026 18:05:31 -0500 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20identity=20lifecycle=20contract=20r?= =?UTF-8?q?ev=202=20=E2=80=94=20address=20independent=20review=20findings?= =?UTF-8?q?=201-9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §3: durable fail-closed epoch + writer-level refusal + atomic transition (adapter must be transaction-bound or bootstrap writes users itself); first-admin SSO defined as the bootstrap writer, not JIT - §5: canonical account email split from per-principal provider claims; explicit email-change workflow only - §6: external principal keyed by issuer+subject, DB-unique, immutable provider->issuer binding; explicit link requires step-up reauth - §2/§4: JIT per-provider default off always, wizard-explicit enable; effective closed during epoch; allowlist requires verified email - §7.1: measurable deactivation (atomic-or-fail-closed ban+revocation, all transports, 30s/next-message socket bound, admin tokens) - §7.3: deletion gated here; hard-delete endpoint fails closed until a chartered deletion/retention contract ratifies (contract 7 is D14 custody) - §1.4: account creates no workspace membership/authority (SOT REQ-TEN-001) - §8: expanded verification matrix with bounded observables - factual fixes: 21 FK constraints across 19 tables; ci-postgres is the postgres service of the test step --- docs/requirements/identity-lifecycle.md | 230 ++++++++++++++++++------ 1 file changed, 173 insertions(+), 57 deletions(-) diff --git a/docs/requirements/identity-lifecycle.md b/docs/requirements/identity-lifecycle.md index 3ce62e96..c196c82e 100644 --- a/docs/requirements/identity-lifecycle.md +++ b/docs/requirements/identity-lifecycle.md @@ -7,9 +7,20 @@ 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). + 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). +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 @@ -21,6 +32,13 @@ wizard UX flow (contract 3), hierarchy schema (contract 1). 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 @@ -33,10 +51,14 @@ 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. +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), each gated separately. + admin-created users or OIDC JIT (§4); each of those is gated by its own + switch, and all of them 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 @@ -52,92 +74,186 @@ 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 +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; 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 + 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. -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). +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 (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). + 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. 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 +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.name`, `users.image`) are set from the provider claims exactly + once, at account creation (JIT or first-admin SSO), and are never silently + overwritten by a later login. +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. A provider-claim refresh never rebinds `users.email`; a + divergence between canonical email and the latest provider-observed email + is surfaced per §6.3. +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. -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` +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. Each configured provider binds to + exactly one issuer; that binding is immutable after creation (changing the + issuer means creating a new provider). The database enforces at most one + local account per (provider, subject) with a unique constraint — the + current non-unique `(provider_id, account_id)` index does not satisfy + this; application-level checks without a uniqueness witness lose + concurrent-callback races. +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). -2. Untrusted-provider email collision produces a login error naming the +3. 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 +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 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. +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. 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. +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. ## 8. Verification requirements -1. The bootstrap invariant (§3) requires a real-PostgreSQL two-connection - concurrency test (pattern: +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`, - runs in the `ci-postgres` CI step). Mocked-transaction specs are + 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) 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. +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 does not touch canonical `users.email`/name/image; explicit + email-change workflow is the only path that rebinds canonical email. +5. **Linking (§6).** Unique-constraint witness: concurrent first-login + callbacks for the same (provider, subject) yield exactly one account; + trusted auto-link; untrusted collision error; explicit link refused + without fresh step-up reauthentication; last-usable-method unlink refusal; + 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. ## Ruling request -- 2.54.0 From e595855adc6218c443592114ac90a155287b3973 Mon Sep 17 00:00:00 2001 From: fred Date: Wed, 26 Aug 2026 18:21:22 -0500 Subject: [PATCH 3/4] docs: identity lifecycle contract revision 3 Addresses the revision-2 re-review residuals and new findings: - emailVerified joins the canonical set; defined reset rule on email change - external-principal uniqueness moved to (issuer, subject) - 'usable login method' defined for unlink refusal - shipped delete affordances (web admin, mosaic CLI) must be removed or disabled with a defined visible state; deactivation exposed instead - admin-creation switch dropped in favor of plain admin authorization - verification matrix extended: IdP removal, forward-auth non-use, first-admin SSO, wizard-recorded JIT choice, step-up positive/expiry, issuer-aliasing witness, UI/CLI delete assertions, docs observable --- docs/requirements/identity-lifecycle.md | 114 +++++++++++++++++++----- 1 file changed, 90 insertions(+), 24 deletions(-) diff --git a/docs/requirements/identity-lifecycle.md b/docs/requirements/identity-lifecycle.md index c196c82e..b6864dfd 100644 --- a/docs/requirements/identity-lifecycle.md +++ b/docs/requirements/identity-lifecycle.md @@ -17,6 +17,18 @@ 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 @@ -57,8 +69,10 @@ Contract: 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); each of those is gated by its own - switch, and all of them are closed while the bootstrap epoch is open (§3). + 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 @@ -138,14 +152,21 @@ in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`): `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.name`, `users.image`) are set from the provider claims exactly - once, at account creation (JIT or first-admin SSO), and are never silently - overwritten by a later login. + `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. A provider-claim refresh never rebinds `users.email`; a - divergence between canonical email and the latest provider-observed email - is surfaced per §6.3. + 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 @@ -155,13 +176,17 @@ in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`): 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. Each configured provider binds to - exactly one issuer; that binding is immutable after creation (changing the - issuer means creating a new provider). The database enforces at most one - local account per (provider, subject) with a unique constraint — the - current non-unique `(provider_id, account_id)` index does not satisfy - this; application-level checks without a uniqueness witness lose - concurrent-callback races. + 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 @@ -178,8 +203,12 @@ in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`): §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 the last login method that the user can actually use is refused - unless a password is set first. +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 @@ -215,6 +244,16 @@ in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`): 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 @@ -241,19 +280,46 @@ Every MUST above needs a bounded observable. The matrix: 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 does not touch canonical `users.email`/name/image; explicit - email-change workflow is the only path that rebinds canonical email. + 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 (provider, subject) yield exactly one account; - trusted auto-link; untrusted collision error; explicit link refused - without fresh step-up reauthentication; last-usable-method unlink refusal; - divergence surfaced after IdP email change. + 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. + 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 -- 2.54.0 From 339e147f775efb0e2f57905ce45db66dbad3f761 Mon Sep 17 00:00:00 2001 From: fred Date: Wed, 26 Aug 2026 18:21:39 -0500 Subject: [PATCH 4/4] style: prettier formatting in identity-lifecycle.md --- docs/requirements/identity-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements/identity-lifecycle.md b/docs/requirements/identity-lifecycle.md index b6864dfd..e69b4a70 100644 --- a/docs/requirements/identity-lifecycle.md +++ b/docs/requirements/identity-lifecycle.md @@ -302,7 +302,7 @@ Every MUST above needs a bounded observable. The matrix: 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. +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). -- 2.54.0