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
263 lines
15 KiB
Markdown
263 lines
15 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.
|
||
|
||
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
|
||
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
|
||
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
|
||
(contract 1), sensitive-data custody (contract 7 / D14).
|
||
|
||
## 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.
|
||
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
|
||
|
||
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 a
|
||
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
|
||
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
|
||
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. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
|
||
lives in a constraint-backed one-row `bootstrap_state` table. While the
|
||
epoch is open, every non-bootstrap user-creating writer — better-auth
|
||
sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
|
||
the writer's own path (better-auth hook for the raw handler; guard for
|
||
admin routes). A partial unique index or a winning epoch-transition row is
|
||
necessary but not sufficient on its own: neither stops an untagged insert
|
||
from a writer that never consulted the epoch. Both layers are required:
|
||
database-level transition safety (the epoch-completing write races safely
|
||
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
|
||
the same pool (`DB_POOL_MAX=1` is a supported configuration).
|
||
4. **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.
|
||
5. **No stranded partial outcome.** A failure at any point in the transition
|
||
leaves nothing observable (no admin user without its token, no completed
|
||
epoch without an admin) and setup remains retryable — this follows from
|
||
§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)
|
||
|
||
1. A successful OIDC login with no matching account creates a user
|
||
just-in-time only when `jit_provisioning` is enabled for that provider.
|
||
The flag is per-provider and defaults off, always. There is no
|
||
mode-implied default: Enterprise setup enables JIT only when the wizard
|
||
records it as an explicit operator choice for a named provider (this
|
||
replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
|
||
enabled", which contradicted the per-provider default).
|
||
2. JIT users receive platform role `member`, never an elevated role,
|
||
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
|
||
|
||
1. **Two stores, not one.** Provider-observed claims (`email`,
|
||
`email_verified`, display name, avatar) are recorded per external
|
||
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
|
||
layer (contract 2). An IdP group/role claim may at most be recorded for
|
||
audit; it grants nothing.
|
||
|
||
## 6. Account linking trust
|
||
|
||
1. **External principal identity is issuer + subject.** A linked identity is
|
||
keyed by the OIDC issuer and subject claims, not by an unqualified
|
||
provider subject id and not by email. Each configured provider binds to
|
||
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).
|
||
3. Untrusted-provider email collision produces a login error naming the
|
||
conflict, not an auto-link and not a duplicate account.
|
||
4. A linked identity whose IdP-observed email later diverges from the
|
||
canonical account email keeps working (the link is by issuer + subject,
|
||
§6.1) but the divergence is surfaced in the user's settings and audit log
|
||
(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.
|
||
|
||
## 7. Deactivation propagation
|
||
|
||
1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
|
||
Concretely:
|
||
- 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
|
||
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, and deletion is gated here.** Account
|
||
deletion semantics (FK fan-out across the 21 foreign-key constraints to
|
||
`users.id`, spread over 19 referencing tables) require their own
|
||
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
|
||
|
||
Every MUST above needs a bounded observable. The matrix:
|
||
|
||
1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
|
||
distinct physical connections (pattern:
|
||
`apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
|
||
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.
|
||
2. **Registration gating (§2).** Spec coverage of all three modes against the
|
||
raw `/api/auth/` handler path, not only Gateway controllers; invite
|
||
lifecycle (single-use, expiry, email binding); effective-`closed` while
|
||
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
|
||
|
||
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.
|