Files
Ekklesia/docs/development/identity.md
T
ekklesia c38b572778
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s
docs: document module database access
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
2026-07-25 21:50:03 -05:00

179 lines
11 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 will eventually authenticate through Core-exposed HTTP/API and client contracts**,
never by querying Better Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that
transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface exists 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, breakpoint splitter, 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
```
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 | An absolute `http`/`https` URL. In production, `https` is required and loopback hosts are rejected. |
| Trusted origins | One or more absolute `http`/`https` origins with no path, query, or fragment. Wildcards are rejected in this slice. In production, `https` is required and loopback hosts are rejected. |
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.
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
ADR 0010.
## 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 for exactly how). 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).
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.
## What is not operational yet
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. Public registration, sign-in, principal resolution, and sign-out over HTTP **are**
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why
public registration is intentional and grants no elevation. Core's authorization foundation
(platform owners, explicit grants, an immutable permission registry) landed in Phase 4C and is
covered separately — see [Authorization](authorization.md) — not a Dashboard Surface, which still
depends on an authorization model that only now exists and has not yet been built on.