Files
Ekklesia/docs/development/identity.md
T
ekklesiaandClaude Opus 5 c8fa13dd6a
CI / verify (pull_request) Successful in 41s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m18s
feat: create the initial platform owner from an operator command
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.

    pnpm identity:create-initial-owner -- --email [email protected]

creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.

The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.

The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.

An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.

Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.

The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.

Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 22:32:53 -05:00

349 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Identity
Phase 4A established Core's identity foundation: a Core-owned Better Auth database schema, a
first-class Core-owned migration source, minimal opaque-identity contracts, and an explicit Better
Auth construction boundary. Phase 4B built the HTTP transport on top of it — see
[API](api.md) and
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for
public registration, sign-in, and principal resolution over HTTP. This page covers the identity
foundation itself; see [ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md)
for that architectural decision.
## Ownership
Authentication and identity are a Core platform concern (ADR 0001), not a feature module and not a
surface concern. Better Auth is an implementation detail contained entirely behind
`core/src/identity/`:
- **Modules never import `better-auth`, `@better-auth/*`, or `auth`, and never receive a Better
Auth instance, session token, or raw database client.** This holds structurally today: modules
cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of
what Core exposes.
- **Surfaces authenticate through Core-exposed HTTP/API contracts**, never by querying Better
Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that transport. The Dashboard
now owns its browser login/logout UX and uses only `/api/auth/sign-in/email`,
`/api/auth/sign-out`, and the narrow `/api/v1/principal` contract; modules receive none of those
controls. Website and Mobile authentication do not exist yet.
- Better Auth's `user` row is an authentication identity, not the future church-person/profile
model. Church membership, ministry roles, contact history, attendance, permissions, and other
module-specific fields must never be added to the identity tables; a future, separately designed
model owns that data, linked only through the opaque user identifier.
## The four Core identity tables
| Better Auth model | Physical table |
| --- | --- |
| `user` | `ekklesia_core_user` |
| `session` | `ekklesia_core_session` |
| `account` | `ekklesia_core_account` |
| `verification` | `ekklesia_core_verification` |
Declared once, by hand, at `core/src/identity/schema.ts`, and passed explicitly to Better Auth's
Drizzle adapter as `identityAdapterSchema`. Columns, defaults, uniqueness, foreign keys (cascading
delete from `session`/`account` to `user`), and indexes mirror the pinned
`[email protected]` generator output exactly; only the table names differ, so ownership is
unambiguous inside the shared `public` PostgreSQL schema. No custom fields are added in this
slice.
## Ekklesia identity contracts
`@ekklesia/contracts` exports the only identity-shaped values available today:
- `EkklesiaUserId` — an opaque, validated string. Parse untrusted input with
`parseEkklesiaUserId`/`safeParseEkklesiaUserId`.
- `EkklesiaPrincipal` — a frozen `{ kind: "anonymous" }` (the `ANONYMOUS_PRINCIPAL` singleton) or
`{ kind: "authenticated", userId }` (built with `createAuthenticatedPrincipal`).
No roles, permissions, organization/campus IDs, group memberships, profile fields, email, session
tokens, or password data are exposed through this contract. Extending it requires a deliberate,
separately reviewed change.
## Core-owned migrations coexist with module migrations
Identity's schema ships as a first-class Core-owned migration source at `core/drizzle/`, in the
exact Drizzle Kit / Ekklesia migration format ADR 0008 and ADR 0009 define — the same journal,
per-file SHA-256 checksum, and `_ekklesia.json` destructive-policy sidecar every module migration
uses. Core is not a module: it has no `ekklesia.module.json` manifest and is never passed through
module discovery. Instead, `core/src/migrations/core-source.ts` discovers `core/drizzle` directly
under the reserved source identifier `"core"`, and
`core/src/migrations/aggregate.ts`'s `discoverAllMigrations` merges it with the existing module
aggregate, **Core first**. An installed module manifest that ever declares `id: "core"` is rejected
at discovery time so Core and module migration identities can never collide.
The transactional runner, advisory lock, whole-batch SQL execution, and the fixed
`public.ekklesia_core_migrations` history table (ADR 0009) are unchanged: Core's migration honestly
populates the existing `module_id` column with the literal value `"core"`, exactly like a module
populates it with its own module ID. There is exactly one migration system and one executor.
## Validating and applying migrations
```bash
pnpm migrations:validate # offline; Core's migration plus every module's, Core first
pnpm migrations:plan # read-only comparison against PostgreSQL applied history
pnpm migrations:apply # locked, transactional application of the pending batch
pnpm doctor # includes Core's migration in its discovery/plan checks
```
Both `migrations:plan` and `migrations:apply` connect with fixed, finite defaults — a 10-second
connection deadline, a 10-second client query timeout, and a 10-second PostgreSQL
`statement_timeout` — applied at the one shared construction boundary so no command can omit them.
A `DATABASE_URL` that tries to set `statement_timeout`, `query_timeout`, `connectionTimeoutMillis`,
`connect_timeout`, or an `options` value naming `statement_timeout` is refused with a sanitized
configuration error. Only `statement_timeout` actually overrides the selected policy under the
pinned driver (measured: `?statement_timeout=0` made the server report `0`); the others are rejected
as unsupported configuration and defense in depth — `query_timeout` breaks the client rather than
loosening it, and `connectionTimeoutMillis` pollutes a driver field the pure-JS client never
consults. Unrelated parameters are untouched. This bounds each individual operation; it does **not**
promise that a migration completes within ten seconds across retries or cleanup. Migrations remain explicit operator work and never run at application
startup.
Each pending migration's SQL is sent to PostgreSQL as **one complete batch**, inside the existing
transaction, so the server does all SQL parsing — Drizzle's `--> statement-breakpoint` lines are
ordinary `--` comments to it. No SQL parser exists in this repository, and the marker's bytes keep
their ordinary meaning inside a string literal, dollar-quoted body, quoted identifier, or comment.
No new commands are required to include identity: it flows through the existing migration
commands automatically. A fresh database applies the Core identity migration through
`pnpm migrations:apply` like any other pending migration; a second application is a genuine no-op.
Doctor, planning, and importing any identity module never create the identity tables — only an
explicit `pnpm migrations:apply` mutates the database.
## Upgrading the Better Auth schema
Better Auth schema changes must never be applied to production directly. Follow this workflow
whenever the pinned Better Auth version changes:
1. Deliberately update the pinned `better-auth`, `auth`, and `drizzle-kit` versions in
`core/package.json`.
2. Regenerate the canonical schema with the pinned local CLI:
`pnpm --filter @ekklesia/core identity:schema:generate` (writes a local, gitignored
`core/.identity-schema-reference.ts` — never committed).
3. Review the diff between that reference file and the checked-in
`core/src/identity/schema.ts`, deliberately updating the latter (table names, relations, and
any structural change) while preserving Core's explicit `ekklesia_core_*` naming.
4. Generate a new forward-only Core migration:
`pnpm --filter @ekklesia/core identity:migration:generate` (drizzle-kit, writes into
`core/drizzle/`). Add the matching entry to `core/drizzle/meta/_ekklesia.json`, declaring
`{"mode":"none"}` or `{"mode":"approval-required","reason":"..."}` for any destructive change.
5. Validate and test it through Ekklesia's existing migration system:
`pnpm migrations:validate`, `pnpm identity:schema:validate`, and
`pnpm test:migrations:integration`.
6. Never edit an already-applied migration. Editing changes its checksum, and Core's runner
refuses an applied migration whose packaged bytes no longer match its recorded history.
`pnpm identity:schema:validate` automates steps 2–3 as a read-only check: it regenerates Better
Auth's canonical schema into a temporary, package-local directory with the pinned local CLI,
structurally compares every core model's columns, types, nullability, defaults, foreign keys, and
indexes against the checked-in schema, and always removes the temporary output — no `DATABASE_URL`
required. It is part of `pnpm verify`.
## Required identity configuration
`createIdentityConfig` (`core/src/identity/config.ts`) validates three explicit values before
`core/src/identity/runtime.ts`'s `createIdentityRuntime` constructs Better Auth. This document
intentionally contains no real values — configure them per environment, never commit them:
| Value | Requirement |
| --- | --- |
| Secret | At least 32 characters, high entropy (for example `openssl rand -base64 32`). No built-in default is ever provided; a missing or too-short secret is rejected explicitly. |
| Base URL | Exactly one canonical `http`/`https` **origin** — scheme, host, and non-default port only. In production, `https` is required and loopback/unspecified hosts are rejected. |
| Trusted origins | One or more canonical `http`/`https` origins. Wildcards are rejected in this slice. In production, `https` is required and loopback/unspecified hosts are rejected. |
**"Canonical origin" is literal**: the value must equal the WHATWG URL parser's own origin
serialization of itself. A path (`https://id.example.test/auth`), a bare trailing slash
(`https://id.example.test/`), a query, a fragment, embedded credentials, an explicitly written
default port (`https://id.example.test:443`), or non-canonical case is rejected with
`BASE_URL_INVALID`/`TRUSTED_ORIGIN_INVALID` rather than normalized behind your back — so the stored
value always matches what a browser actually sends in an `Origin` header.
Production rejects loopback and unspecified hosts by classifying the address, not by comparing it
against a short list: `localhost` and the whole reserved `.localhost` namespace (RFC 6761), all of
`127.0.0.0/8` (not just `127.0.0.1`), `0.0.0.0`, `::1` in both compressed and expanded spellings,
`::`, and IPv4-mapped forms such as `::ffff:127.0.0.1`. Local development origins (for example
`http://localhost:3000`) are only accepted when the caller explicitly passes
`environment: "development"` — never silently. Every configuration failure raises a sanitized
`IdentityConfigError` carrying only a stable code, never the offending value.
This is a guarantee about the *shape* of the configured value and nothing more. It does not resolve
DNS and does not prove reachability: a public hostname that resolves to a loopback or private
address passes this validation, and a firewall, network policy, or reverse proxy — not URL
parsing — is what actually determines who can reach a deployment.
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
ADR 0010. The Dashboard flow in ADR 0034 deliberately retains a host-only cookie: a future
deployment must not assume sibling subdomains share a session.
## The identity runtime (Core's Better Auth transport boundary)
`@ekklesia/core/identity` publicly exports `createIdentityRuntime({config, db})`
(`core/src/identity/runtime.ts`), which constructs Better Auth explicitly and returns a frozen
`EkklesiaIdentityRuntime` exposing exactly two Fetch-standard capabilities:
`handleAuthRequest(request): Promise<Response>` (delegates directly to Better Auth's handler; its
normal responses, including its own error responses, pass through unmodified) and
`resolvePrincipal(headers): Promise<EkklesiaPrincipal>` (never silently downgrades a database
outage to an anonymous result — see ADR 0011 and [session resolution](#how-a-principal-is-resolved)
below). Neither method, nor anything else
exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the
Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the
only current consumer of this boundary; see [API](api.md).
Core still returns Better Auth's normal `Response` to that consumer. At the browser-facing API
composition boundary, [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md)
defines exactly two exceptions. First, any body-bearing successful `2xx` from
`POST /api/auth/sign-in/email` preserves Better Auth's status, headers, and HttpOnly `Set-Cookie`
but replaces its token/user JSON with fixed `{"ok":true}`. Second, the token-bearing native paths
`/api/auth/get-session`, `/api/auth/list-sessions`, `/api/auth/update-session`, and
`/api/auth/change-password` receive a sanitized `404` without reaching Better Auth. The last path
is denied even though password/account management is not built, because the pinned implementation
can return a new raw session token when revoking other sessions.
Failures and every other supported auth endpoint retain their native semantics. The Dashboard
ignores even the normalized sign-in body as identity and re-resolves `/api/v1/principal`, the only
supported browser session read; no session token is exposed to browser application JavaScript or
added to a module context.
Construction also explicitly configures four things the pinned release would otherwise leave on an
environment-sensitive or implicit default, all covered by [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md)
and [API](api.md#logging):
`advanced.disableCSRFCheck`/`disableOriginCheck` are both pinned to `false` (the default silently
skips both under a test runner), Better Auth's `logger` is replaced with one that drops its message
and arguments and emits only a fixed, generic string for error-level events, `onAPIError: { throw:
true }` routes a genuinely unexpected failure out to `handleAuthRequest`'s own sanitized boundary
instead of letting Better Auth's `better-call` transport dependency log it raw through a path no
`logger` configuration can reach, and `emailAndPassword.minPasswordLength`/`maxPasswordLength` are
pinned to `8`/`128` instead of left on Better Auth's fallback default.
`createIdentityDrizzleDatabase` (`core/src/identity/database.ts`) also accepts an optional
`operationTimeoutMs`, applied as both `pg`'s `query_timeout` and `statement_timeout` — bounding a
hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bounds establishing
the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is
rejected the same sanitized way an invalid connection string already is. It also accepts an optional
`transport` policy — the PostgreSQL TLS decision described in
[API § Database transport](api.md#database-transport-tls); every production executable passes one.
## How a principal is resolved
`resolvePrincipal(headers)` returns the anonymous principal for a missing, expired, revoked, or
malformed session, and throws a sanitized `PRINCIPAL_RESOLUTION_FAILED` for an infrastructure
failure. Telling those apart takes explicit work, because Better Auth's `getSession` swallows
internal failures and returns `null` for them exactly as it does for a genuinely absent session.
1. **No cookies, or only unrelated cookies → no identity database work at all.** `Cookie: x=y` is
not evidence that Better Auth ever attempted to resolve an Ekklesia session, so its `null` is
unambiguous and the anonymous principal is returned immediately. An unauthenticated caller
cannot drive identity database load with a cookie header they chose themselves.
2. **The configured session cookie is present, and Better Auth returned `null` → the ambiguous
case, and only then is the session store checked.** Detection is an exact, whole-name cookie
comparison (RFC 6265 parsing; names are case-sensitive and never percent-decoded), so
`better-auth.session_token_x` and `x_better-auth.session_token` are correctly *not* the session
cookie. The name itself is derived from the same options object passed to Better Auth, via its
own exported `getCookies` helper — including the `__Secure-` prefix an `https` base URL implies
— so there is no second copy of it to drift.
3. **The check reads the real session relation**, not `select 1`: it plans a read of the exact
columns the Better Auth adapter depends on under a `where false` predicate. PostgreSQL resolves
the relation, every named column, and the caller's `SELECT` privilege while matching zero rows,
so no session row, token, or user id can be read, logged, or enumerated by the check. It runs on
the injected pool — never a second connection — under a bounded 3-second timeout.
A missing session table, a dropped or drifted column, a revoked `SELECT` privilege, an unreachable
database, and a query timeout therefore all produce a sanitized
`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` — never a silent anonymous result. The response body,
logs, and error carry no relation name, column name, SQL, driver message, host, or stack. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
## What is and is not operational
Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow
are operational — see [API](api.md) and ADR 0034. Public registration remains a future product
policy, not a production capability: it is disabled by default, may be explicitly enabled only for
development/test, and production rejects it. No email verification, password recovery, invitation
flow, email delivery, social or OAuth provider, passkey, magic link, two-factor authentication, or
roles/groups/teams/tenants model exists.
Core's authorization foundation (platform owners, explicit grants, and an immutable permission
registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants
an existing identity; it does not create one. Creating the *first* identity on a fresh installation
is what `pnpm identity:create-initial-owner` is for — see below.
## Creating the first platform owner
A fresh installation has a login page and nothing to log in to. Public registration is disabled by
default and production cannot enable it at all, so one explicit, operator-invoked command creates
the initial identity and grants it platform ownership
([ADR 0035](../architecture/decisions/0035-initial-platform-owner-bootstrap.md)):
```bash
pnpm migrations:apply # the schema must already exist; this command never migrates
DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email [email protected]
# Password: (typed invisibly)
# Confirm password:
# Initial platform owner created successfully.
# Email: [email protected]
# You can now sign in to the Ekklesia dashboard.
```
`--name` is optional and defaults to the email address; the identity model requires a display name,
and asking for one that nobody can edit yet would be a question without a purpose.
### The password never becomes an argument or a variable
It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an
explanation**, because a command-line argument is visible in shell history and to every process on
the machine through the process list. For automated deployment the password is read from standard
input:
```bash
printf '%s' "$PASSWORD" | pnpm identity:create-initial-owner -- --email [email protected]
```
**No environment variable is read for it, and none belongs in `.env`.** A bootstrap password is used
once to create an account; it is not deployment configuration, and an environment variable is
precisely the thing that quietly becomes permanent. Nothing prints the password, its length, a hash,
a session token, or the database URL.
### Required environment
`DATABASE_URL`, and the same optional transport variables every other Core command honours
(`NODE_ENV`, `EKKLESIA_DATABASE_TLS_MODE`, `EKKLESIA_DATABASE_CA`). Deliberately **not** the identity
secret, trusted origins, or a public base URL: none is needed to create an account, so the first
owner can be created on a freshly provisioned server before TLS or DNS exist.
### Password and email rules are the identity system's own
The 8–128 character bound and email validation are enforced by Better Auth through the same sign-up
path a browser would use, not by a separate CLI policy that could drift from it. The address is
normalized exactly as a browser sign-up normalizes it — stored lowercased — and the command reports
the stored address, so the one you are told to sign in with is the one that exists.
### It runs once
Ownership bootstrap is permanently single-use (see
[Authorization](authorization.md#bootstrap-is-permanently-single-use)). A second run is refused and:
- creates no second owner,
- changes no existing password,
- creates no stray identity, even when a different address is supplied.
### Rerun and recovery
| situation | behaviour |
| --- | --- |
| Bootstrap already completed | Refused. Nothing changes. |
| Identity exists, ownership was never granted | Ownership is granted to it and **its password is left unchanged**. Reported as an adoption, not a creation. |
| A `user` row exists with no password (cannot sign in) | Refused, and bootstrap stays available — granting ownership to an identity nobody can authenticate as would consume the single use and leave the installation unusable. |
| Identity created but the grant failed | Reported as incomplete. The identity is real and bootstrap is still available: run the identical command again with the same address to finish. |
| Database unmigrated or unreachable | Refused with a clear message. The command never migrates. |
Nothing is deleted automatically on failure: removing an account that was created successfully, on
the strength of a later error, would destroy something an operator may need.
### This is not signup
There is no `/signup`, `/register`, or `/setup` route, no unauthenticated bootstrap endpoint, no
temporary setup route, and no setup token. The login page stays login-only, and public registration
stays disabled in every environment — an integration test asserts that a freshly bootstrapped
deployment still refuses `POST /api/auth/sign-up/email`.
Creating additional users or owners is not this command's job. That belongs to user administration,
which does not exist yet.