Files
Ekklesia/docs/architecture/decisions/0019-durable-authorization-history-and-grant-management.md
T
ekklesiaandClaude Opus 5 5c589d8fec 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]>
2026-07-26 21:56:32 -05:00

18 KiB
Raw Blame History

0019: Durable authorization history and grant management

  • Status: Accepted
  • Date: 2026-07-26
  • Amends: 0013
  • Extends: 0017, 0018

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.