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:
co-authored by
Claude Opus 5
parent
532ce3803b
commit
5c589d8fec
+270
@@ -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.
|
||||
Reference in New Issue
Block a user