docs: identity account-lifecycle contract (S2 contract 4, D10/Q1=O1; folds #1430 bootstrap invariant)
ci/woodpecker/pr/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user