docs: identity account-lifecycle contract (S2 contract 4, D10/Q1=O1; folds #1430 bootstrap invariant)
ci/woodpecker/pr/ci Pipeline was canceled

This commit is contained in:
fred
2026-08-26 17:49:24 -05:00
parent 49b7943420
commit 4d3de6c58c
+146
View File
@@ -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 18 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.