Files
stack/docs/requirements/identity-lifecycle.md
T

7.7 KiB
Raw Blame History

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.