fix: preserve authorization security history

Two security records did not survive user deletion, because both hung off a
cascading foreign key to the live Better Auth user row.

Deleting a user with authorization history deleted that history. A subject with
a create and a revoke audit row had zero rows afterwards: the record of what
they had been granted, by whom, and when it was revoked disappeared at exactly
the moment it mattered most. `ekklesia_core_authorization_audit.subject_user_id`
is now a retained, non-null historical snapshot with no foreign key — a plain
identifier that may name a user row which no longer exists. No name, email, or
other profile data is copied into authorization tables. The Drizzle relation to
the user table is dropped too, because modelling a retained snapshot as a live
relation misdescribes the data. Live rows still cascade, as intended: deleting a
user removes their grants and platform-owner row, so nothing continues
authorizing a principal that no longer exists.

Platform-owner bootstrap's single use was equally fragile. "Already consumed"
meant "a row exists in ekklesia_core_platform_owner", and that table cascades
too — so deleting the bootstrapped owner's user row removed the owner row, its
audit evidence, and with them the only thing preventing the operation that
establishes a platform's first administrator from being run again. A dedicated
`ekklesia_core_platform_owner_bootstrap` singleton now carries that authority.
Its id is pinned by a check constraint to the literal 'singleton', so the
primary key makes a second row structurally impossible rather than merely
unwritten by convention; it has no user foreign key, and Core never deletes from
it. Bootstrap claims that record inside the same transaction as the owner row
and its audit entry, so consumption is tied to success: a missing target user or
any other failure rolls the sentinel back and leaves the one legitimate first
use available. The existing transaction-scoped advisory lock still serializes
concurrent first attempts to exactly one success.

The operational consequence is deliberate and permanent: deleting the last
platform owner does not reactivate bootstrap, and neither does revoking one.

Core migration 0002_authorization_integrity creates the singleton table, drops
the cascading audit-subject constraint, and backfills consumed state for any
installation that already holds a platform-owner row — active or revoked — so an
existing deployment cannot gain a fresh first use by upgrading. It records no
subject, because a backfill cannot know which of several historical owner rows
the original use targeted and guessing would fabricate history. A never-owned
installation is untouched and keeps its one first use.

One upgrade limitation is irrecoverable and is documented rather than papered
over: if an operator manually deleted the bootstrapped owner's user row before
this migration, the old cascade already erased the owner row and its audit
evidence, so nothing remains to detect. At this checkpoint no supported Better
Auth or API user-deletion path can produce that state; it requires direct
database access.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 21:54:51 -05:00
co-authored by Claude Opus 5
parent 6b6b77c105
commit 532ce3803b
12 changed files with 1373 additions and 53 deletions
+199 -23
View File
@@ -20,8 +20,10 @@ registered permission, Core-owned or module-owned; a module can register its own
have them mounted, enforced, and reachable in production (see
[API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble
instead of silently allowing or denying. It is **not** possible to manage roles, groups, teams, or
tenants; there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status
is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated
tenants; there is no user-deletion endpoint, owner-management HTTP endpoint, or owner-recovery
workflow (revoking or re-granting platform-owner status is not exposed at all, and deleting the last
owner does not reactivate bootstrap); there are no service accounts or API tokens, so only
cookie-authenticated
browser callers can reach mutation routes, Core-owned or module-owned; and relationship-derived
grants (for example, automatic access from a roster assignment) cannot yet be created by anything
in this repository — the generic grants API structurally cannot create or revoke them either.
@@ -33,7 +35,41 @@ Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner`
`AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope`
branded types). This page does not restate that design — read `core/src/authorization/schema.ts`,
`evaluator.ts`, and `store.ts` directly for it. This page covers what pass 2 added on top: the
permission registry, the bootstrap operation, and HTTP enforcement.
permission registry, the bootstrap operation, and HTTP enforcement — plus the Phase 5D pass 3
integrity rules recorded in
[ADR 0019](../architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
### What user deletion removes, and what it does not
Deleting an identity user still cascades to that user's **live** authorization rows: their grants
(`ekklesia_core_grant`) and their platform-owner row (`ekklesia_core_platform_owner`) are removed,
so nothing continues authorizing a principal that no longer exists.
It does **not** remove security records. `ekklesia_core_authorization_audit.subject_user_id` is a
retained, non-null historical snapshot with no foreign key to the user table, and
`ekklesia_core_platform_owner_bootstrap` has no user foreign key at all. Both survive user
deletion. Previously the audit subject cascaded, so deleting a user destroyed exactly the record of
what they had been granted, by whom, and when it was revoked.
Only opaque identifiers are retained — a subject id and an actor id. No name, email, or other
profile data is ever copied into an authorization table.
**Append-only is an application convention, not a database guarantee.** Core's authorization store
exposes no update or delete path for audit rows. Nothing in PostgreSQL enforces that: no role,
trigger, or rule prevents a database administrator, a future migration, or anything else holding
the same credentials from modifying them. They are not cryptographically immutable.
### Deploying these changes
All of the above depends on Core migration `0002_authorization_integrity`, which creates
`ekklesia_core_platform_owner_bootstrap`, drops the cascading audit-subject foreign key, and
backfills consumed state. **Migrations remain explicit operator work and never run at application
startup** — apply `0002` with `pnpm migrations:apply` *before* starting the new code, exactly as
with every other migration. It is classified `{"mode":"none"}` in
`core/drizzle/meta/_ekklesia.json`: it creates a table, removes one referential constraint
specifically to *retain* data, and inserts one backfill row; it drops no table, column, or row.
Migrations `0000_identity_core_tables` and `0001_authorization_core_tables` are byte-identical and
retain their existing checksums.
## Permission registry
@@ -236,13 +272,37 @@ twice.
`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated,
Hono-independent Core operation — deliberately *not* a composition of "check for an owner, then
call the existing grant function." "Already consumed" means **any row has ever existed** in
`ekklesia_core_platform_owner`, for any user, including one whose `revoked_at` is set. There is no
owner-management flow in this slice that clears that state; a revoked owner staying revoked (and
bootstrap staying consumed) is intentional here.
call the existing grant function."
The single-use check, the target user's existence check, the owner-grant insert, and its audit
entry are all one PostgreSQL transaction. The very first statement inside that transaction is a
"Already consumed" means **the singleton row exists** in `ekklesia_core_platform_owner_bootstrap`
— a dedicated Core-owned table with `id` pinned by a check constraint to the literal `'singleton'`
and **no foreign key to any user**. Core never deletes from it.
This is deliberately no longer "an owner row exists". That marker cascaded on user deletion, so
deleting the bootstrapped owner's user row removed the owner row *and* its audit evidence, after
which the one operation that establishes a platform's first administrator could be run again. The
operational consequence of the fix is explicit and intended: **deleting the last platform owner
does not reactivate bootstrap**, and neither does revoking one. Re-establishing an owner after that
requires a separately designed owner-management flow, which does not exist yet. There is no reset
command, deletion path, HTTP endpoint, environment override, or force flag.
Upgrading an existing installation is handled by Core migration `0002_authorization_integrity`,
which inserts the singleton if any platform-owner row already exists — active or revoked — so an
existing deployment cannot gain a fresh first use. It records no `consumed_for_user_id`, because a
backfill cannot know which of several historical owner rows the original single use targeted, and
guessing would fabricate history. An installation that never had an owner is left untouched and
keeps its one first use.
One upgrade limitation is irrecoverable: if an operator manually deleted the bootstrapped owner's
user row *before* this migration, the old cascade already erased both the owner row and its audit
evidence, and the migration has nothing left to detect — such an installation backfills as
never-bootstrapped. At the current checkpoint no supported Better Auth or API user-deletion path
exists that could produce this state; it requires direct database access.
The sentinel claim, the target user's existence check, the owner-grant insert, and its audit entry
are all one PostgreSQL transaction. Consumption is therefore tied to *success*: a missing target
user, a constraint violation, or any other failure rolls the sentinel back with everything else,
leaving the one legitimate first use available. The very first statement inside that transaction is a
transaction-scoped advisory lock:
```sql
@@ -281,7 +341,7 @@ path, or stack — only a stable `code` and a fixed message:
| Code | Meaning |
| --- | --- |
| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | A platform-owner row already exists (any user, revoked or not). |
| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | The durable bootstrap-consumed singleton exists; bootstrap has already been used and cannot be used again. |
| `PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. |
| `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). |
@@ -337,28 +397,84 @@ generic HTTP API structurally cannot create or revoke a relationship-derived gra
| Route | Origin required | Notes |
| --- | --- | --- |
| `GET /api/v1/grants?userId=<id>` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). |
| `POST /api/v1/grants` | Yes | Creates a new explicit grant. |
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. |
| `GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>` | No | One bounded page of the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side on every page). |
| `POST /api/v1/grants` | Yes | Creates a new **unscoped** explicit grant. |
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant, and every unrevoked equivalent of it, without deleting anything. |
`:grantId` must be the canonical lowercase hyphenated UUID PostgreSQL emits (`8-4-4-4-12` lowercase
hexadecimal). Uppercase, whitespace-padded, shortened, overlong, and non-UUID values are rejected
with `400 {"code":"INVALID_REQUEST"}` after authorization but strictly before any store or database
call — previously such a value reached PostgreSQL's own UUID comparison and returned an
indistinguishable `503`. A *canonical* id that does not exist still returns the sanitized `404`.
### `POST /api/v1/grants` request body
Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`,
`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or
anything else) makes the **entire body** invalid rather than being silently dropped, so a caller
cannot smuggle a server-derived field through a request that otherwise looks well-formed:
Only three keys are accepted; any other key present (`scope`, `actor`, `owner`, `isOwner`,
`sourceKind`, `sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`,
`effectivePermissions`, or anything else) makes the **entire body** invalid rather than being
silently dropped, so a caller cannot smuggle a server-derived field through a request that
otherwise looks well-formed:
| Field | Required | Notes |
| --- | --- | --- |
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in the running API's composed platform registry (Core's own declarations plus every installed module's — see [API startup](#api-startup-discovery-composition-and-enforcement)). A malformed value is a `400`; a well-formed but unregistered one is a `403`. |
| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. |
| `expiresAt` | No | An ISO date string if present. |
| `expiresAt` | No | Exactly `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to the parsed date's own `toISOString()`, and strictly in the future. |
The server always derives, and the request body can never supply: the grant id, `sourceKind`
(always `"explicit"`), the actor (always the authenticated caller, via
`createUserActor(principal.userId)`), and all audit/timestamp fields.
**The generic HTTP surface creates unscoped explicit grants only.** A `scope` field is rejected in
every form — including `null`, empty, malformed, and otherwise valid scope strings — with
`400 {"code":"INVALID_REQUEST"}` before any store mutation. No HTTP route middleware supplies a
scope to the evaluator today, so a scope accepted here would produce a grant that persists, appears
in listings, and can authorize nothing. Core's `AuthorizationScope`, the evaluator's exact-scope
semantics, and the store's scope column are unchanged and remain available to trusted internal
callers; listing still shows a scope on historical or internally created grants. **Scopes cannot
authorize a module HTTP route today**, and no route-parameter-to-scope resolver was added.
`expiresAt` deliberately accepts one representation and nothing else. Rejected: offsets
(`2027-01-01T00:00:00.000+02:00`), missing milliseconds (`2027-01-01T00:00:00Z`), excess fractional
precision, lowercase separators, whitespace padding, date-only (`2027-01-01`), locale forms
(`March 5, 2027`), extra trailing text, normalized impossible dates (`2026-02-31T00:00:00.000Z`,
which bare `new Date()` silently rewrote to March 3rd), and any instant at or before the acceptance
clock. Core's store independently rejects an invalid or non-future `Date` before any SQL, so a
trusted internal caller cannot create a dead-on-arrival grant either. A rejected expiration creates
no grant and no audit row. There is no maximum lifetime, renewal workflow, scheduler, or background
expiration job.
### Duplicate creation and all-equivalent revocation
Two grant issuances are the **same authorization source** when all five of these are equal, with
`NULL` equal only to `NULL`: `user_id`, `permission_id`, `scope`, `source_kind`, `source_ref`.
`expires_at`, creation metadata, actor metadata, and the grant id do not distinguish them.
Creating a grant while an equivalent one is *currently effective* (`revoked_at IS NULL` and
`expires_at IS NULL OR expires_at > now()`) fails with
`409 {"code":"AUTHORIZATION_GRANT_ALREADY_EXISTS"}` and `Cache-Control: no-store`. The response
names no existing grant id, actor, scope, source reference, expiry, database detail, SQL, or stack —
only the code. A rejected duplicate creates no grant and no audit row. A revoked or already-expired
equivalent does not block a fresh issuance. This is a conflict rather than an idempotent return of
the existing grant: the caller asked to record a new authorization with their own actor and expiry,
and answering "done" while recording neither would hide that nothing happened.
Enforcement is database-backed, not process-local: every create and revoke touching one equivalence
tuple takes a transaction-scoped `pg_advisory_xact_lock` keyed by a hash of that tuple, so multiple
API processes serialize identically. Its single-key lock space is disjoint from the two-key space
used by the migration runner and platform-owner bootstrap.
**One revocation revokes every unrevoked equivalent**, including expired-but-unrevoked legacy rows,
in one transaction, writing one `grant.revoke` audit row per grant actually revoked, each carrying
that row's own grant id. Grants with a different subject, permission, scope, source kind, or source
reference are untouched. This replaces behavior where revoking one of two equivalent grants returned
`success` while the other kept authorizing the same user. The public success response remains
`200 {"success": true}`.
Historical duplicates are **not** silently revoked by any migration. The store now prevents creating
another effective equivalent, and an operator's explicit revocation resolves the whole equivalence
class.
### Response shapes
A grant, wherever returned:
@@ -376,9 +492,48 @@ A grant, wherever returned:
}
```
`GET /api/v1/grants` → `200 {"grants": [...]}`. `POST /api/v1/grants` → `201 {"grant": {...}}`.
`POST /api/v1/grants` → `201 {"grant": {...}}`.
`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`.
`GET /api/v1/grants` → `200 {"grants": [...], "nextCursor": <string|null>}`.
### Listing is bounded keyset pagination
```text
GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>
```
| Parameter | Required | Notes |
| --- | --- | --- |
| `userId` | Yes | The subject whose explicit grants to list. |
| `limit` | No | Canonical base-10 integer, 1 through 100. Defaults to 50. |
| `cursor` | No | An opaque position string returned as a previous page's `nextCursor`. |
Unknown query keys, duplicated `userId`/`limit`/`cursor` keys, empty values, non-canonical numbers
(`0`, `101`, `-1`, `+5`, `01`, `1.5`, `1e2`, whitespace-padded), and malformed cursors are all
rejected with `400 {"code":"INVALID_REQUEST"}` before any store or database work. Permission
enforcement completes first, so query validation never discloses a validation distinction — or the
existence of any subject or grant — to an unauthorized caller.
Rows are ordered `created_at DESC, id DESC`, with a strict keyset boundary on **both** values, so
grants sharing a `created_at` can neither be skipped nor repeated across pages. At most `limit + 1`
rows are read internally and at most `limit` returned. The final page returns `"nextCursor": null`.
Relationship grants never appear on any page. There is no total count, offset pagination, search, or
sorting option.
The cursor is a small versioned payload containing only the subject user id, boundary creation
timestamp, and boundary grant id, encoded as canonical unpadded base64url. Decoding is strict —
exact version, exact keys, a subject matching the *requested* one, a canonical `toISOString()`
timestamp, a canonical `GrantId`, and a re-encoding that reproduces the supplied string
byte-for-byte — and a rejected cursor reveals nothing it decoded.
**It is a pagination position, not an authorization token.** It is deliberately unsigned: the
permission check and the server-side subject filter remain authoritative on every page, so a forged
cursor can at most name a different position within grants the caller may already list. It carries
no permission, scope, source reference, actor data, credential, or secret, and it is not a
consistency snapshot — rows created or revoked between pages are seen or not seen according to the
ordering.
### Status-code mapping
| Status | When |
@@ -387,7 +542,8 @@ A grant, wherever returned:
| `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). |
| `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`; the caller's own required permission id was unregistered; or, for `POST /api/v1/grants`, the well-formed *target* permission named in the body is not registered. |
| `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. |
| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. |
| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The canonical grant id does not exist as an unrevoked *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. |
| `409 {"code":"AUTHORIZATION_GRANT_ALREADY_EXISTS"}` | An equivalent grant is already currently effective for the subject. |
| `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). |
Every response sets `Cache-Control: no-store`.
@@ -453,7 +609,23 @@ module database usage), and exactly one `pool.end()` call regardless of how many
being imported.
- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
still blocking reuse, and the concurrency scenario described above.
still blocking reuse, and the concurrency scenario described above. Pass-3 scenarios add: exactly
one consumed row plus one owner row plus one audit row in one transaction; deleting the owner's
user row keeping the sentinel and the audit history while removing the live owner row and not
reactivating bootstrap; a grant subject's create/revoke audit rows surviving that subject's
deletion while their live grant cascades; a forced failure after the sentinel was claimed rolling
everything back so the one first use is not burned; and migration `0002` backfilling consumed
state for an active *and* a revoked existing owner while leaving a never-owned installation with
exactly one first use.
- `core/tests/authorization-store.integration.ts` (live PostgreSQL) — pass-3 grant-integrity
scenarios: sequential and six-way concurrent equivalent creation yielding exactly one grant and
one create audit row; a differing expiry not bypassing the conflict; different subject/permission/
scope/source-kind/source-ref being non-equivalent; fresh issuance after a revoked or expired
equivalent; one revocation clearing every unrevoked equivalent (including an expired-but-unrevoked
one) with per-row audit entries while leaving differently scoped or sourced grants untouched; a
concurrent create/revoke race serializing without an unexplained duplicate; a forced transaction
failure rolling back every grant and audit change; and bounded keyset pagination traversing seven
rows that all share one `created_at` with no duplicates or omissions.
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior;
a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/
@@ -466,7 +638,11 @@ module database usage), and exactly one `pool.end()` call regardless of how many
unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing
independently after a composition failure, and the exact documented terminal report shape.
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
and malformed-field rejection, a well-formed but unregistered target permission rejected with
and malformed-field rejection (including every form of a `scope` field), canonical-`GrantId` and
canonical-future-`expiresAt` rejection with the store never called, the sanitized `409` conflict,
the full pagination contract (default/boundary limits, non-canonical limit rejection, unknown and
duplicated query keys, multi-page traversal, and every malformed or foreign cursor),
a well-formed but unregistered target permission rejected with
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
store-error mapping.
+8
View File
@@ -113,6 +113,14 @@ database failure and asserts, against actual `console.error`/`warn`/`log` output
Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never
emits a raw exception, SQL, connection detail, or the rejected request's origin.
A dedicated group in the API suite also drives Pass 3's explicit-grant invariants through a real
authorized grants-management session: a malformed non-UUID revoke returning a sanitized `400` with
no database work, a canonical unknown UUID returning `404`, a scoped create body returning `400`
with nothing written, normalized-impossible/non-canonical/past expirations returning `400`, an
equivalent creation returning a sanitized `409` that names no existing grant, a bounded paginated
list traversed across three pages with relationship grants absent from every one, and one HTTP
revocation clearing every unrevoked legacy equivalent with a per-row audit entry.
A third dedicated suite (`apps/api/tests/module-database.integration.ts`, included in the same
`pnpm test:migrations:integration` run) proves `context.services.database` against a real,
installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the