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