147 lines
7.7 KiB
Markdown
147 lines
7.7 KiB
Markdown
# 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.
|