docs: identity lifecycle contract revision 3
ci/woodpecker/pr/ci Pipeline is pending

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
This commit is contained in:
fred
2026-08-26 18:21:22 -05:00
parent f507b2bc79
commit e595855adc
+90 -24
View File
@@ -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 reconciled with the native-kanban SOT (§1.4), verification matrix expanded
(§8), factual labels corrected (§7.3, §8.1). (§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.15.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 Scope: account creation, bootstrap, federated login, account linking, claim
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema 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 regardless of any stored value: the setting takes effect only after the
epoch completes. epoch completes.
3. `closed` blocks self-service email/password sign-up. It does not block 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 admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is
switch, and all of them are closed while the bootstrap epoch is open (§3). 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 4. `invite` requires a single-use, expiring invite token bound to an email
address. Invite issuance is an admin operation and is audit-logged. address. Invite issuance is an admin operation and is audit-logged.
5. Enforcement point: a better-auth hook (or equivalent middleware executed 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 `email_verified`, display name, avatar) are recorded per external
principal — keyed by issuer + subject (§6.1) — at first login and principal — keyed by issuer + subject (§6.1) — at first login and
refreshed at each login. The canonical account fields (`users.email`, refreshed at each login. The canonical account fields (`users.email`,
`users.name`, `users.image`) are set from the provider claims exactly `users.emailVerified`, `users.name`, `users.image`) are set exactly once
once, at account creation (JIT or first-admin SSO), and are never silently at account creation and are never silently overwritten by a later login.
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 2. **Canonical email changes only through an explicit workflow.** Either the
user-initiated email change (with verification of the new address) or an user-initiated email change (with verification of the new address) or an
admin edit. A provider-claim refresh never rebinds `users.email`; a admin edit. Any canonical email change — user- or admin-initiated — sets
divergence between canonical email and the latest provider-observed email `users.emailVerified` to false until the new address completes
is surfaced per §6.3. 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 3. Never mapped from IdP claims: `role` and any future authorization
attribute. Authorization lives in the system of record and in the RBAC 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 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 1. **External principal identity is issuer + subject.** A linked identity is
keyed by the OIDC issuer and subject claims, not by an unqualified keyed by the OIDC issuer and subject claims, not by an unqualified
provider subject id and not by email. Each configured provider binds to provider subject id and not by email. The linked-identity row stores the
exactly one issuer; that binding is immutable after creation (changing the issuer, and the database enforces at most one local account per
issuer means creating a new provider). The database enforces at most one **(issuer, subject)** with a unique constraint on those stored columns —
local account per (provider, subject) with a unique constraint — the uniqueness on (provider, subject) is insufficient because provider →
current non-unique `(provider_id, account_id)` index does not satisfy issuer is not one-to-one: two provider configurations can point at the
this; application-level checks without a uniqueness witness lose same issuer, and the identity must not alias across them. The current
concurrent-callback races. 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 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, ways: (a) explicit link initiated by the logged-in user from settings,
which requires step-up: a fresh reauthentication (password or existing 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 §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 (the per-principal claim store in §5.1 is what makes the divergence
representable). representable).
5. Unlinking the last login method that the user can actually use is refused 5. Unlinking a login method is refused when it would leave the account with
unless a password is set first. 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 ## 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 closed, and deactivation is the only supported removal operation. A
contract that merely declared deactivation "the only supported removal" contract that merely declared deactivation "the only supported removal"
while the endpoint stayed live would be false on its face. 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 ## 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; allowlist rejects an unverified email claim even when the domain matches;
JIT refused while the epoch is open. JIT refused while the epoch is open.
4. **Claim mapping (§5).** Login refresh updates the per-principal claim 4. **Claim mapping (§5).** Login refresh updates the per-principal claim
store and does not touch canonical `users.email`/name/image; explicit store and touches none of the canonical fields (`users.email`,
email-change workflow is the only path that rebinds canonical 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 5. **Linking (§6).** Unique-constraint witness: concurrent first-login
callbacks for the same (provider, subject) yield exactly one account; callbacks for the same (issuer, subject) yield exactly one account, and
trusted auto-link; untrusted collision error; explicit link refused two provider configurations sharing one issuer cannot create two accounts
without fresh step-up reauthentication; last-usable-method unlink refusal; for the same subject; trusted auto-link; untrusted collision error;
divergence surfaced after IdP email change. 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 6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed
(partial-failure injection); guard denial post-ban on each transport: (partial-failure injection); guard denial post-ban on each transport:
HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated
within the 30-second/next-message bound; banned user's admin tokens within the 30-second/next-message bound; banned user's admin tokens
unusable; hard-delete endpoint returns a fail-closed error while the 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 ## Ruling request