docs: identity lifecycle contract rev 2 — address independent review findings 1-9
ci/woodpecker/pr/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
- §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
This commit is contained in:
@@ -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
|
issue #1430, folded in here after PR #1431's independent review showed the
|
||||||
quick-fix approach was insufficient.
|
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
|
Scope: account creation, bootstrap, federated login, account linking, claim
|
||||||
mapping, and deactivation. Out of scope: RBAC grant semantics (contract 2),
|
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
|
||||||
wizard UX flow (contract 3), hierarchy schema (contract 1).
|
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
|
||||||
|
(contract 1), sensitive-data custody (contract 7 / D14).
|
||||||
|
|
||||||
## 1. System of record
|
## 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
|
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
|
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.
|
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
|
## 2. Registration gating
|
||||||
|
|
||||||
@@ -33,10 +51,14 @@ Contract:
|
|||||||
1. A single server-side setting `registration_mode` with values
|
1. A single server-side setting `registration_mode` with values
|
||||||
`open | invite | closed`. It lives in the database (admin-mutable at
|
`open | invite | closed`. It lives in the database (admin-mutable at
|
||||||
runtime), not in env config.
|
runtime), not in env config.
|
||||||
2. Default after bootstrap: `closed`. The wizard (contract 3) may set it
|
2. Default after bootstrap: `closed`. The wizard (contract 3) may set a
|
||||||
during setup; Enterprise mode defaults to `closed` with OIDC JIT enabled.
|
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
|
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
|
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
|
||||||
@@ -52,92 +74,186 @@ path writes users.**
|
|||||||
Constraints any implementation MUST satisfy (each traces to a verified defect
|
Constraints any implementation MUST satisfy (each traces to a verified defect
|
||||||
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):
|
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):
|
||||||
|
|
||||||
1. **All-writer coverage or database enforcement.** The invariant must hold
|
1. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
|
||||||
against every user-creating path: bootstrap setup, better-auth sign-up,
|
lives in a constraint-backed one-row `bootstrap_state` table. While the
|
||||||
OIDC JIT, and admin creation. An advisory lock taken only in the bootstrap
|
epoch is open, every non-bootstrap user-creating writer — better-auth
|
||||||
controller does not satisfy this; a concurrent sign-up can commit between
|
sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
|
||||||
the locked count and the admin insert. Acceptable mechanisms: a partial
|
the writer's own path (better-auth hook for the raw handler; guard for
|
||||||
unique index / constraint-backed one-row `bootstrap_state` table whose
|
admin routes). A partial unique index or a winning epoch-transition row is
|
||||||
transition insert races safely at the database level, or registration_mode
|
necessary but not sufficient on its own: neither stops an untagged insert
|
||||||
enforcement (§2) that provably closes every non-bootstrap writer while the
|
from a writer that never consulted the epoch. Both layers are required:
|
||||||
epoch is open.
|
database-level transition safety (the epoch-completing write races safely
|
||||||
2. **Pool safety.** No design may hold a pooled connection inside a
|
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
|
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
|
the same pool (`DB_POOL_MAX=1` is a supported configuration).
|
||||||
`drizzleAdapter(db)` uses the root pool, not the caller's transaction).
|
4. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin
|
||||||
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
|
epoch completes, re-running the wizard reconfigures the system but never
|
||||||
re-opens the zero-user transition. "Setup already completed" is a stable,
|
re-opens the zero-user transition. "Setup already completed" is a stable,
|
||||||
testable state, and factory-reset (a future, explicitly destructive
|
testable state, and factory-reset (a future, explicitly destructive
|
||||||
operation) is the only way to open a new epoch.
|
operation) is the only way to open a new epoch.
|
||||||
4. **Atomic outcome.** The first-admin transition either yields admin user +
|
5. **No stranded partial outcome.** A failure at any point in the transition
|
||||||
initial credential/token together or nothing observable. A failure may not
|
leaves nothing observable (no admin user without its token, no completed
|
||||||
strand an admin user without its token such that setup can never retry
|
epoch without an admin) and setup remains retryable — this follows from
|
||||||
(the pre-existing failure mode noted in the #1431 review).
|
§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)
|
## 4. JIT provisioning (OIDC first login)
|
||||||
|
|
||||||
1. A successful OIDC login with no matching account creates a user
|
1. A successful OIDC login with no matching account creates a user
|
||||||
just-in-time only when `jit_provisioning` is enabled (per-provider flag,
|
just-in-time only when `jit_provisioning` is enabled for that provider.
|
||||||
default off).
|
The flag is per-provider and defaults off, always. There is no
|
||||||
2. JIT users receive role `member`, never an elevated role, regardless of IdP
|
mode-implied default: Enterprise setup enables JIT only when the wizard
|
||||||
claims (§5).
|
records it as an explicit operator choice for a named provider (this
|
||||||
3. An optional per-provider email-domain allowlist constrains JIT. Empty
|
replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
|
||||||
allowlist with JIT on means any authenticated subject at that IdP gets an
|
enabled", which contradicted the per-provider default).
|
||||||
account — permitted, but the wizard must present it as an explicit choice.
|
2. JIT users receive platform role `member`, never an elevated role,
|
||||||
4. JIT is disabled while the bootstrap epoch is open (§3.1).
|
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
|
## 5. Claim mapping
|
||||||
|
|
||||||
1. Mapped at first login and refreshed at each login: `email`,
|
1. **Two stores, not one.** Provider-observed claims (`email`,
|
||||||
`email_verified`, display name, avatar.
|
`email_verified`, display name, avatar) are recorded per external
|
||||||
2. Never mapped from IdP claims: `role` and any future authorization
|
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
|
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
|
||||||
audit; it grants nothing.
|
audit; it grants nothing.
|
||||||
3. Email change at the IdP does not silently rebind the account (§6.3).
|
|
||||||
|
|
||||||
## 6. Account linking trust
|
## 6. Account linking trust
|
||||||
|
|
||||||
1. Linking an OIDC identity to an existing account happens only in one of two
|
1. **External principal identity is issuer + subject.** A linked identity is
|
||||||
ways: (a) explicit link initiated by the logged-in user from settings, or
|
keyed by the OIDC issuer and subject claims, not by an unqualified
|
||||||
(b) automatic link when the IdP asserts a verified email exactly matching
|
provider subject id and not by email. Each configured provider binds to
|
||||||
an existing account **and** the provider is marked `trusted_for_linking`
|
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).
|
(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.
|
conflict, not an auto-link and not a duplicate account.
|
||||||
3. A linked identity whose IdP email later diverges from the account email
|
4. A linked identity whose IdP-observed email later diverges from the
|
||||||
keeps working (the link is by provider subject id, not email) but the
|
canonical account email keeps working (the link is by issuer + subject,
|
||||||
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
|
||||||
4. Unlinking the last login method that the user can actually use is refused
|
(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.
|
unless a password is set first.
|
||||||
|
|
||||||
## 7. Deactivation propagation
|
## 7. Deactivation propagation
|
||||||
|
|
||||||
1. Deactivation (better-auth admin ban) is authoritative and immediate: all
|
1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
|
||||||
sessions for the user are revoked at ban time, and banned users fail every
|
Concretely:
|
||||||
guard, including active websocket sessions, within one heartbeat interval.
|
- 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
|
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
|
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
|
without banning them in Mosaic leaves any password or other linked login
|
||||||
method usable — the admin guide must state this. SCIM/webhook-driven
|
method usable — the admin guide must state this. SCIM/webhook-driven
|
||||||
propagation is future work and out of scope here.
|
propagation is future work and out of scope here.
|
||||||
3. Deletion is not deactivation. Account deletion semantics (FK fan-out
|
3. **Deletion is not deactivation, and deletion is gated here.** Account
|
||||||
across the 21 referencing tables) are deferred to the custody contract
|
deletion semantics (FK fan-out across the 21 foreign-key constraints to
|
||||||
(contract 7 / D14); until ratified, deactivation is the only supported
|
`users.id`, spread over 19 referencing tables) require their own
|
||||||
removal operation.
|
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
|
## 8. Verification requirements
|
||||||
|
|
||||||
1. The bootstrap invariant (§3) requires a real-PostgreSQL two-connection
|
Every MUST above needs a bounded observable. The matrix:
|
||||||
concurrency test (pattern:
|
|
||||||
|
1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
|
||||||
|
distinct physical connections (pattern:
|
||||||
`apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
|
`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.
|
supplementary; they cannot prove serialization.
|
||||||
2. Registration gating (§2) requires spec coverage of all three modes against
|
2. **Registration gating (§2).** Spec coverage of all three modes against the
|
||||||
the raw `/api/auth/` handler path, not only against Gateway controllers.
|
raw `/api/auth/` handler path, not only Gateway controllers; invite
|
||||||
3. Linking (§6) requires specs for: trusted auto-link, untrusted collision
|
lifecycle (single-use, expiry, email binding); effective-`closed` while
|
||||||
error, and last-usable-method unlink refusal.
|
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
|
## Ruling request
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user