329 lines
20 KiB
Markdown
329 lines
20 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).
|
||
|
||
Revision 3: addresses the residuals and new findings of the revision-2
|
||
re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) —
|
||
`users.emailVerified` added to the canonical set with a defined reset rule on
|
||
email change (§5.1–5.2), external-principal uniqueness moved to
|
||
(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped
|
||
delete affordances (web admin page, `mosaic auth users delete`) required to
|
||
be removed or disabled with a defined user-visible state (§7.3), the
|
||
admin-creation switch removed in favor of plain admin authorization (§2.3),
|
||
and §8 extended with observables for IdP removal, forward-auth non-use,
|
||
first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and
|
||
positive/expiry-bound step-up cases.
|
||
|
||
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). Post-bootstrap admin creation is
|
||
gated by admin authorization alone — there is no separate switch for it.
|
||
JIT is gated by its per-provider flag (§4.1). All user-creating paths 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.emailVerified`, `users.name`, `users.image`) are set exactly once
|
||
at account creation and are never silently overwritten by a later login.
|
||
For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified`
|
||
is set from the provider's `email_verified` claim at creation; for
|
||
password-created accounts it is false until the address completes
|
||
verification.
|
||
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. Any canonical email change — user- or admin-initiated — sets
|
||
`users.emailVerified` to false until the new address completes
|
||
verification; an admin may instead explicitly attest the address as
|
||
verified in the same operation, and that attestation is audit-logged. A
|
||
provider-claim refresh never rebinds `users.email` or
|
||
`users.emailVerified`; a divergence between canonical email and the latest
|
||
provider-observed email is surfaced per §6.4.
|
||
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. The linked-identity row stores the
|
||
issuer, and the database enforces at most one local account per
|
||
**(issuer, subject)** with a unique constraint on those stored columns —
|
||
uniqueness on (provider, subject) is insufficient because provider →
|
||
issuer is not one-to-one: two provider configurations can point at the
|
||
same issuer, and the identity must not alias across them. The current
|
||
non-unique `(provider_id, account_id)` index satisfies neither;
|
||
application-level checks without a uniqueness witness lose
|
||
concurrent-callback races. Each configured provider additionally binds to
|
||
exactly one issuer, immutable after creation (changing the issuer means
|
||
creating a new provider).
|
||
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 a login method is refused when it would leave the account with
|
||
no **usable** login method. Usable means: a set password, or a linked
|
||
identity whose provider is currently configured and enabled on this
|
||
deployment. A linked identity whose provider has been removed or disabled
|
||
(§1.2) is not usable and does not count; setting a password first lifts
|
||
the refusal.
|
||
|
||
## 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.
|
||
Disabling the endpoint alone is insufficient — its shipped callers must
|
||
not be left as advertised operations that now fail generically:
|
||
- The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx`
|
||
and any SPA port of it) is removed, or replaced by a disabled control
|
||
whose visible text states that deletion is unavailable pending the
|
||
deletion-and-retention contract and points at deactivation.
|
||
- The CLI command `mosaic auth users delete`
|
||
(`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero
|
||
with a message stating the same and naming the deactivation command.
|
||
- Both surfaces expose deactivation as the supported operation.
|
||
|
||
## 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 touches none of the canonical fields (`users.email`,
|
||
`users.emailVerified`, name, image); explicit email-change workflow is the
|
||
only path that rebinds canonical email; every canonical email change
|
||
resets `users.emailVerified` to false unless the admin attestation path
|
||
is taken, and that attestation appears in the audit log.
|
||
5. **Linking (§6).** Unique-constraint witness: concurrent first-login
|
||
callbacks for the same (issuer, subject) yield exactly one account, and
|
||
two provider configurations sharing one issuer cannot create two accounts
|
||
for the same subject; trusted auto-link; untrusted collision error;
|
||
step-up both ways: an explicit link succeeds immediately after a fresh
|
||
reauthentication and is refused once the implementation's chosen bound
|
||
(≤ 10 minutes) has elapsed, and refused with no reauthentication at all;
|
||
unlink refusal when no remaining method is usable per §6.5, including the
|
||
removed-provider case, and acceptance after a password is set; 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; the admin web UI renders no live delete
|
||
action (absent, or disabled with the §7.3 text) and `mosaic auth users
|
||
delete` exits non-zero with the §7.3 message — both asserted by spec.
|
||
7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal:
|
||
deleting a provider configuration leaves every user row intact and every
|
||
other login method working (spec over the provider-config removal path).
|
||
Forward-auth non-use: with in-app OIDC configured, a request carrying
|
||
forward-auth identity headers and no session is treated as anonymous —
|
||
no code path derives identity from those headers (negative spec at the
|
||
Gateway entry). First-admin SSO: the §3.6 transition commits account,
|
||
token, and epoch atomically from the asserted identity, and fault
|
||
injection mid-transition leaves nothing observable (same harness as §8.1).
|
||
Wizard-recorded JIT choice: enabling JIT for a provider writes an
|
||
explicit per-provider operator-choice record, and no mode selection
|
||
enables it implicitly (assert the stored record, not UI behavior).
|
||
8. **Documentation observable (§7.2).** The admin guide contains the
|
||
IdP-removal-does-not-deactivate statement; verified by a docs assertion
|
||
(content check in CI or an enumerated review-checklist item on the
|
||
implementing PR) — a MUST about documentation needs a checkable artifact,
|
||
not intent.
|
||
|
||
## 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.
|