fix: enforce explicit grant invariants

Explicit-grant management could produce states an operator could not reason
about, and pushed request-shaped problems into the database.

Two identical createGrant calls produced two grants; five concurrent identical
calls produced five. Revoking one returned success while the equivalent kept
authorizing the same user — an operator could believe they had removed access
that was still live. Two issuances are now the same authorization source when
user_id, permission_id, scope, source_kind, and source_ref are all equal, with
NULL equal only to NULL; expiry, creation metadata, actor, and grant id do not
distinguish them. Creating one while an equivalent is currently effective fails
with AUTHORIZATION_GRANT_ALREADY_EXISTS (HTTP 409, no-store) naming nothing about
the grant that already exists, and writing no grant and no audit row. A revoked
or expired equivalent does not block a fresh issuance. Conflict rather than
idempotent return: 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, since nothing constrains the number of API
processes: every create and revoke touching one equivalence tuple takes a
transaction-scoped advisory lock keyed by a hash of that tuple, encoded as a JSON
array so NULL stays distinct from the empty string and a crafted scope cannot
forge a separator. A collision can only make unrelated tuples serialize, never
hide a conflict, because the decision is always an exact SQL predicate. Its
single-key lock space is verified disjoint from the two-key space the migration
runner and bootstrap use. A time-dependent partial unique index was rejected:
now() is not immutable and cannot be indexed.

One revocation now clears every unrevoked equivalent — including expired-but-
unrevoked legacy rows, so the history is left in one state — with one audit row
per grant actually revoked, each carrying its own id, in one transaction.
Differently scoped or sourced grants are untouched. Historical duplicates are not
silently revoked by migration; the store prevents new effective equivalents and
an operator's explicit revocation resolves the class.

The request boundary no longer leaks into PostgreSQL. GrantId accepts exactly the
canonical lowercase hyphenated UUID the database emits, with no trimming and no
case folding, so a malformed path parameter is a 400 decided after authorization
but before any store call instead of reaching the UUID comparison and returning
an indistinguishable 503; a canonical unknown id still returns 404. expiresAt
accepts exactly YYYY-MM-DDTHH:mm:ss.sssZ, byte-for-byte equal to the parsed
date's own toISOString, and must be strictly future — bare new Date silently
rewrote 2026-02-31 to March 3rd and accepted locale, date-only, offset, and
millisecond-free forms, and a past expiry was stored as a grant dead on arrival.
The store validates the same thing before any SQL so trusted internal callers get
the guarantee too.

The generic HTTP surface now rejects a scope field in every form, including null.
No route middleware supplies a scope to the evaluator, so a scope accepted here
produced a grant that persisted, listed, and authorized nothing. Core's scope
semantics are unchanged for internal callers; no resolver was added.

Listing was unbounded and returned a user's entire history. It is now keyset
pagination over created_at DESC, id DESC with a strict boundary on both values,
so rows sharing a timestamp are neither skipped nor repeated, at most limit + 1
rows read, explicit-source filtering server-side on every page, and an opaque
unsigned position cursor that is strictly decoded and reveals nothing on
rejection. Core's list method requires a validated finite bound with no
unbounded fallback anywhere.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 21:56:32 -05:00
co-authored by Claude Opus 5
parent 532ce3803b
commit 5c589d8fec
15 changed files with 2177 additions and 107 deletions
@@ -129,6 +129,17 @@ touching grant semantics.
### Platform-owner bootstrap is explicit, CLI-only, globally single-use, permanently consumed even after revocation, concurrency-safe, and audited atomically
> **Partial correction — see [ADR 0019](0019-durable-authorization-history-and-grant-management.md).**
> Defining "bootstrap has already been consumed" as *any row ever created in
> `ekklesia_core_platform_owner`*, described immediately below, was not durable: that table's
> `user_id` cascades on user deletion, so deleting the bootstrapped owner's user row removed the
> owner row and reopened the one-time operation. ADR 0019 moves that authority to a dedicated
> `ekklesia_core_platform_owner_bootstrap` singleton with no user foreign key, backfilled for
> existing installations. The intent recorded here — explicit, CLI-only, globally single-use,
> permanently consumed even after revocation, concurrency-safe, and audited atomically — is
> unchanged and still accepted; only the marker it is read from moved. This section is left as
> originally accepted, as the historical record.
There is no signup-order or first-user inference anywhere in this repository. The only way to
establish a platform owner is `pnpm authorization:bootstrap-owner -- --user-id <id>
--confirm-user-id <same-id>` (`core/src/cli/bootstrap-platform-owner.ts`,
@@ -0,0 +1,270 @@
# 0019: Durable authorization history and grant management
- Status: Accepted
- Date: 2026-07-26
- Amends: [0013](0013-core-authorization-and-owner-bootstrap.md)
- Extends: [0017](0017-public-request-perimeter-hardening.md), [0018](0018-identity-session-resolution-and-database-transport.md)
## Context
Phase 5D passes 1 and 2 closed the public request perimeter and the identity/database transport
behind it. Pass 3 addresses the authorization layer those protect: whether security history
survives, whether the one-time bootstrap that creates a platform's first administrator is really
one-time, and whether explicit-grant creation, revocation, validation, and listing are unambiguous
and bounded. Ten defects were reproduced against the accepted pass-2 tree before anything changed.
**Security history did not survive.** `ekklesia_core_authorization_audit.subject_user_id` carried a
cascading foreign key to the live Better Auth user row. Deleting a user with two audit rows left
zero: the record of what that user had been granted, by whom, and when it was revoked disappeared
at precisely the moment it mattered most.
**Bootstrap's single use was not durable.** "Already consumed" meant "a row exists in
`ekklesia_core_platform_owner`" — and that table also cascades on user deletion. Deleting the
bootstrapped owner's user row removed the owner row *and* its audit evidence, after which
`bootstrapPlatformOwner` succeeded again for a different user. There was no state independent of
user lifecycle recording that bootstrap had ever run.
**Equivalent grants multiplied silently.** Two identical `createGrant` calls produced two grants;
five concurrent identical calls produced five. Revoking one returned `success` while an equivalent
issuance kept authorizing the same user — an operator could believe they had removed access that
was still live.
**The request boundary leaked into the database.** The generic HTTP API accepted a `scope` even
though no route middleware supplies one to the evaluator, so a scoped grant persisted, appeared in
listings, and could authorize nothing. `GrantId` accepted any 1–128-character string, so a
malformed path parameter reached PostgreSQL's own UUID comparison and returned an indistinguishable
`503`. `expiresAt` went through bare `new Date()`, which silently normalized `2026-02-31` to March
3rd and accepted `"March 5, 2027"`, date-only, offset, and millisecond-free forms; a past
expiration was persisted as a grant that was dead on arrival. Listing had no bound at all and
returned a user's entire authorization history in one response.
## Decision
### Security history is retained, not referential
`ekklesia_core_authorization_audit.subject_user_id` stays a non-null `text` column and loses its
foreign key to `ekklesia_core_user`. It is now a **historical snapshot**: an identifier that may
name a user row which no longer exists. No name, email, or other profile data is copied into
authorization tables — a deleted user's audit history says what happened, not who they were. The
subject index is preserved, and the Drizzle relation to the user table is deliberately removed
because modelling a retained snapshot as a live relation would misdescribe the data.
Live rows still cascade on user deletion, which is intended: deleting a user removes their grants
and their platform-owner row, so nothing continues authorizing a principal that no longer exists.
Only the *history* is exempt.
**Append-only remains an application/store 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. This ADR claims exactly the property that
exists.
### Bootstrap consumption is its own durable record
`ekklesia_core_platform_owner_bootstrap` is a dedicated single-row table meaning "platform-owner
bootstrap was successfully consumed." 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 foreign key to any user**, and Core never deletes from it, so
it survives owner revocation and user deletion alike. An optional `consumed_for_user_id` records
which user the original bootstrap made an owner, in the same retained-identifier style as the audit
subject.
`bootstrapPlatformOwner` consults and claims that record — never the continued existence of a
platform-owner row. The claim, the target-user check, the owner insert, and the audit entry are one
transaction, serialized by the existing transaction-scoped advisory lock, so exactly one of
concurrent first attempts succeeds and the other receives
`PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED`. Consumption is tied to *success*: a missing target
user (`PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND`), a constraint violation, or any other failure
rolls the sentinel back with everything else, leaving the one legitimate first use available.
There is deliberately no reset command, deletion path, HTTP endpoint, environment override, force
flag, or second bootstrap mechanism. The operational consequence is explicit: **deleting the last
platform owner does not reactivate bootstrap.** Re-establishing an owner after that is a
separately designed owner-management flow that does not exist yet.
Core migration `0002_authorization_integrity` backfills the sentinel 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 deliberately leaves `consumed_for_user_id` null, 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, because a
legitimately never-bootstrapped deployment must retain its one first use.
**One upgrade limitation is irrecoverable and is stated rather than papered over.** 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 and retains one bootstrap use. At the accepted
checkpoint no supported Better Auth or API user-deletion path exists that could produce this state;
it requires direct database access. The migration cannot reconstruct an event for which no durable
evidence remains, and does not pretend to.
### Grant equivalence is exactly five values
Two grant issuances are the same authorization source when all five are equal, with `NULL` equal
only to `NULL`:
1. `user_id`
2. `permission_id`
3. `scope`
4. `source_kind`
5. `source_ref`
`expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
distinguish them. Re-issuing the identical authority with a different expiry, or by a different
administrator, is the same grant — not a second one.
Creation fails with the stable `AUTHORIZATION_GRANT_ALREADY_EXISTS` when an equivalent grant is
currently effective (`revoked_at IS NULL` and `expires_at IS NULL OR expires_at > now()`). A revoked
or already-expired equivalent does not block a fresh issuance. A rejected duplicate creates no grant
and no audit row.
**Conflict, not idempotent return.** Returning the existing grant would silently answer a different
question than the caller asked — the caller wanted a new authorization recorded with their own
actor and expiry, and telling them "done" while recording neither is the ambiguity this pass exists
to remove. An explicit 409 lets an operator see that the authority already exists and decide.
### Serialization 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 64-bit hash of that tuple encoded as a JSON array — which keeps
`NULL` distinct from the empty string and makes separator forgery through a crafted scope or source
reference impossible. A hash collision is harmless by construction: it can only make two unrelated
tuples serialize against each other, never make a real conflict go unnoticed, because the
authoritative decision is always an exact SQL predicate and never the hash.
A process-local mutex would not do this — nothing constrains the number of API processes — and
neither would an unlocked check-then-insert. `pg_advisory_xact_lock(bigint)` uses PostgreSQL's
single-key advisory-lock space, verified disjoint from the two-key space the migration runner and
platform-owner bootstrap use, so grant serialization can never block or be blocked by either.
A **time-dependent partial unique index** (`... WHERE revoked_at IS NULL AND expires_at > now()`)
was rejected outright: `now()` is not immutable, so PostgreSQL cannot index on it, and any
approximation would either over-constrain legitimate re-issuance after expiry or silently stop
enforcing.
### Revocation resolves the whole equivalence class
Revocation is still addressed by one grant id and retains the explicit-versus-relationship source
guard. Once the target is found, one successful revocation revokes **every** currently unrevoked
issuance sharing its equivalence tuple — including expired-but-unrevoked legacy equivalents, so the
history is left in one unambiguous state — and inserts one `grant.revoke` audit row per grant
actually revoked, each carrying that row's own grant id. All updates and audit inserts are one
transaction. Unknown, already-revoked, and wrong-source ids keep the existing not-found behavior,
and the public success response remains `200 {"success":true}`.
Historical duplicates are **not** silently revoked by migration. The store now prevents creating
another effective equivalent, and revocation removes all equivalents when an operator explicitly
revokes one. Choosing a historical "winner" or changing live authorization inside a data migration,
with no operator action, is not something this pass will do.
### The generic HTTP surface creates unscoped explicit grants only
`POST /api/v1/grants` rejects the presence of a `scope` field — including `null`, empty, malformed,
and otherwise valid scope strings — with `400 {"code":"INVALID_REQUEST"}` before any store mutation.
Core's `AuthorizationScope`, the evaluator's exact-scope semantics, and the store's scope column are
all unchanged and remain available to trusted internal callers and to future server-owned scope
work; listing and serialization still show a scope on historical or internally created grants.
No route-parameter-to-scope resolver is added in this pass, and no module route declaration or
handler context changes. Accepting a scope that nothing can evaluate would be offering a switch that
is not connected to anything.
### Canonical identifiers and future-only expiration
`GrantId` accepts exactly the canonical lowercase hyphenated UUID PostgreSQL emits (`8-4-4-4-12`
lowercase hexadecimal), with no trimming. Uppercase, whitespace-padded, shortened, overlong, and
non-UUID values are rejected rather than repaired, so exactly one spelling of any grant is valid and
a malformed id is a `400` decided after authorization but strictly before any store or database
call. A canonical id that does not exist still returns the sanitized `404`.
`expiresAt` accepts exactly `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to the parsed `Date`'s
own `toISOString()`, and must be strictly later than the authoritative acceptance clock — equal-to-
now and past are invalid. The round-trip comparison is what rejects normalized impossible dates.
The Core store independently rejects an invalid or non-future `Date` before any SQL, using the
stable internal `AUTHORIZATION_GRANT_EXPIRATION_INVALID`, so trusted internal callers get the same
guarantee; the public HTTP response stays exactly `400 {"code":"INVALID_REQUEST"}` with
`Cache-Control: no-store`. A rejected expiration creates no grant and no audit row. A deterministic
clock is injectable only at the narrow Core store and API route factory seams needed for tests;
production leaves it unset. No maximum lifetime, renewal workflow, scheduler, or background
expiration job is introduced.
### Listing is bounded keyset pagination with an opaque, unsigned cursor
`GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>` returns
`{"grants": [...], "nextCursor": string | null}`. `limit` defaults to 50 and must be a canonical
base-10 integer; unknown query keys, duplicated keys, empty values, non-canonical numbers, and
malformed cursors are rejected with `400 {"code":"INVALID_REQUEST"}` before any store or database
work. Ordering is `created_at DESC, id DESC` with a strict row-value keyset boundary on **both**
columns, so rows sharing a `created_at` — which concurrent creation makes routine — can neither be
skipped nor repeated. At most `limit + 1` rows are read and at most `limit` returned.
`sourceKind: "explicit"` filtering stays server-side on every page. Offset pagination was rejected
because it does exactly the skipping and repeating this ordering avoids.
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 in
every dimension — exact version, exact keys, matching requested subject, canonical `toISOString()`
timestamp, canonical `GrantId`, and a re-encoding that reproduces the supplied string byte-for-byte
— and a rejected cursor reveals nothing it decoded.
**The cursor 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 is already entitled to
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 simply seen or not seen
according to the ordering.
Core's list method requires and validates a finite bound; no internal call site retains an unbounded
fallback. No total count, offset pagination, search, sorting options, or filtering beyond the
existing subject and explicit-source restriction is added.
## Alternatives
- **Keeping the audit foreign key and adding `ON DELETE SET NULL`** was rejected: a null subject is
not history, and the column's whole purpose is naming who the security event concerned.
- **Copying the user's name or email into the audit row so history stays readable** was rejected: it
converts an authorization table into a shadow profile store and would survive a deletion the user
may have requested.
- **Treating "an owner row exists" as consumed, but blocking the cascade instead** was rejected: an
owner row is live authorization state that an operator may legitimately want removed, while the
fact that bootstrap ran is a historical event. Conflating the two is what produced the defect.
- **A `--force`/reset path for bootstrap** was rejected as directly reintroducing the vulnerability
under a different name.
- **Guessing "consumed" for every upgraded database** was rejected: it would burn the one legitimate
first use of a never-bootstrapped installation.
- **Idempotently returning the existing grant on duplicate creation** — see above.
- **A partial unique index over the equivalence tuple** was rejected for the `now()` immutability
reason above; the advisory lock covers the complete tuple with no time dependence.
- **Revoking only the addressed grant and reporting how many equivalents remain** was rejected: it
puts the burden of resolving an ambiguous state on the operator, in a response that would have to
disclose other grants' existence.
- **Silently revoking historical duplicates in the migration** was rejected as changing live
authorization without operator action.
- **Adding a route-parameter-to-scope resolver in this pass** was rejected as scope creep with
security consequences; rejecting scoped creation is the honest interim state.
- **Signing the pagination cursor** was rejected: it would imply the cursor carries authority it does
not have, and invite treating it as a capability rather than a position.
## Consequences
Authorization audit history survives user deletion; live grants and platform-owner rows still
cascade, as intended. Bootstrap's single use is durable across owner revocation and user deletion,
backfilled for existing installations with one documented, irrecoverable exception, and permanently
unavailable once consumed — including after the last owner is deleted. Two equivalent grants can no
longer exist effectively, sequentially or concurrently, and one revocation leaves no equivalent
issuance authorizing the user. Malformed grant ids and expirations are request-validation results
decided before any database call, never infrastructure failures. Explicit-grant listing is bounded
and deterministically traversable.
Core gains one table and one forward migration (`0002_authorization_integrity`), classified
`{"mode":"none"}`: it creates a table, drops one referential constraint specifically to *retain*
data, and inserts a backfill row. It drops no table, column, or row. The `DROP CONSTRAINT` text is
not itself a reason to classify it approval-required — the production CLI has no trusted external
approval provider, so an approval-blocked security fix would be undeployable. Migration 0002 must be
applied before the new code starts; migrations 0000 and 0001 are byte-identical with unchanged
checksums.
No roles, groups, teams, tenants, service accounts, API tokens, scope resolver, user-deletion
endpoint, owner-recovery workflow, or module-facing change is introduced, and the trusted compiled-
backend module model is unchanged. Audit append-only behavior remains an application convention, not
a cryptographic or database-role guarantee. **Phase 5D is not complete** — passes 4 (module/runtime/
migration correctness) and 5 (deployment/supply chain plus an independent whole-repository re-audit)
remain outstanding, and Phase 6 remains blocked.
+1
View File
@@ -27,3 +27,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0016: Module database access](0016-module-database-access.md) — Accepted
- [0017: Public request perimeter hardening — client address, signup gating, body limits, timeouts](0017-public-request-perimeter-hardening.md) — Accepted
- [0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy](0018-identity-session-resolution-and-database-transport.md) — Accepted
- [0019: Durable authorization history and grant management](0019-durable-authorization-history-and-grant-management.md) — Accepted
+7 -2
View File
@@ -15,7 +15,12 @@ Phase 5D pass 1 hardened this same public perimeter against an independent secur
server-owned client-address resolution boundary for the rate limiter, public sign-up disabled by
default, an explicit request-body-size policy for every public route (including a module route's
own declared contract), and finite Node header/request timeouts — see
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md). Pass 3 made the
explicit-grant API's own request contracts exact: canonical UUID grant ids, one canonical
future-only expiration format, unscoped-only creation, a `409` duplicate conflict, and bounded
keyset listing — see
[Authorization](authorization.md#explicit-grant-http-api) and
[ADR 0019](../architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
## What this is, and is not
@@ -69,7 +74,7 @@ No real values are shown here — configure per environment and never commit the
| --- | --- | --- |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a