Files
Ekklesia/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md
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

0013: Core authorization, permission registry, and platform-owner bootstrap

  • Status: Accepted
  • Date: 2026-07-23

Context

ADR 0010 and ADR 0011 established Core-owned identity and its HTTP transport but explicitly left authorization out of scope: every account received identical baseline authenticated status, and signup order never granted elevation. docs/architecture/platform-architecture.md names permissions as a Core concern (ADR 0001), not a feature module or a surface concern, alongside identity, database connectivity, and module lifecycle.

Phase 4C adds that missing piece in two slices on this branch. The first slice (feat: add core authorization foundation) established the schema, evaluator, store, and contracts. The second (feat: add authorization bootstrap and HTTP enforcement) added an explicit platform-owner bootstrap operation, a permission registry, and HTTP enforcement wired into apps/api. This ADR records the durable architectural decisions both slices established, since together they fix a shape future changes must not casually violate: what a permission is, who can grant one, how a platform owner is established, and how any of this reaches HTTP.

Decision

Core owns authorization independently of Hono, Surfaces, and modules

Authorization is a Core platform concern, exactly like identity. The schema (core/src/authorization/schema.ts), evaluator (core/src/authorization/evaluator.ts), store (core/src/authorization/store.ts), permission registry (core/src/authorization/permissions.ts), and bootstrap operation (core/src/authorization/bootstrap.ts) import nothing from hono, apps/api, or any module, and are independently testable without an HTTP transport. apps/api consumes Core's authorization surface the same way it already consumes identity: through explicit, injected composition (apps/api/src/authorization-middleware.ts, apps/api/src/grants-routes.ts), never the reverse. Modules cannot import Core at all under scripts/check-boundaries.ts, so this holds structurally, not just by convention.

Permission IDs are exact, registered, and deny by default

A PermissionId (@ekklesia/contracts) is a validated, namespaced string (core.authorization.grants.manage), but being well-formed is not the same as being registered. PermissionRegistry (core/src/authorization/permissions.ts) is the trusted, immutable, composition-time-built source of which permissions actually exist and what baseline each requires. Matching is always exact string equality — there is no wildcard, prefix, or namespace-inheritance matching anywhere in the evaluator or the store; a permission core.authorization.grants.manage never implies or is implied by core.authorization.grants.* or core.authorization.*. An unknown or malformed permission id is treated identically to a denied decision, never a distinct "this would work if declared" response — deny-by-default holds even for permissions nobody has declared yet.

Registration is enforced everywhere a permission id can originate from outside Core's own trusted route wiring, not only for the caller's own required permission. requirePermission (apps/api/src/authorization-middleware.ts) checks the registry for the permission a route requires; POST /api/v1/grants (apps/api/src/grants-routes.ts) separately checks the registry for the target permission a caller is requesting be granted to someone else, since that value comes from the request body rather than route wiring. Without the second check, a caller with core.authorization.grants.manage could create a stored grant for a permission id that is merely well-formed, not registered — a dormant grant that would silently become effective the moment that identifier was registered later, with no further action or review at registration time. Both checks reject with the identical sanitized 403 {"code":"AUTHORIZATION_DENIED"} an ordinary denial would return, and neither ever calls into the store on rejection.

Permissions declare one trusted baseline: public, authenticated, or restricted

Every registered permission declares exactly one PermissionBaseline at registration time. The evaluator (core/src/authorization/evaluator.ts) still accepts a baseline as part of its own request shape — that is pass 1's evaluator contract, unchanged — but the only caller allowed to supply it in this repository is Core's own trusted registry lookup (requirePermission in apps/api/src/authorization-middleware.ts); no HTTP route reads a baseline from a request header, query parameter, or body. public always allows; authenticated requires a non-anonymous principal and nothing else; restricted requires an authenticated principal and either platform-owner authority or an active matching grant — the baseline alone never satisfies a restricted check.

Unknown permissions and client-supplied baselines fail closed

Two independent fail-closed layers exist on purpose, not one relied on twice, and they check different things. The registry itself (PermissionRegistry.get/.has) returns undefined/false for anything malformed or unregistered — this is the only layer that knows what is registered. requirePermission treats a registry miss as an immediate sanitized 403, before principal resolution or evaluation ever runs; POST /api/v1/grants performs the equivalent registry check for the target permission named in its body (see above). Separately, createAuthorizationEvaluator independently re-validates permission shape — it holds no registry and cannot recognize registration at all — denying (without throwing) any malformed or unrecognized permission/baseline/scope it receives. This shape check runs, and can deny, before any baseline is allowed to return an allowed decision — public and authenticated are not exempt, even though neither reads the permission value for any other reason — so a future caller that reaches the evaluator directly, bypassing the registry entirely (for example a different transport Core does not yet have), inherits the same fail-closed guarantee for malformed input rather than depending on a registry check having already run upstream. The evaluator's shape check and the registry's registration check are deliberately not merged into one: only the registry can answer "is this permission registered," and the evaluator must remain usable — and safe against malformed input — by a caller that has no registry to consult at all.

Explicit and relationship-derived grants share persistence but have distinct source kinds

ekklesia_core_grant is one table for both explicit grants (an administrator naming a subject user, permission, and optional scope directly) and relationship-derived grants (a future module operation establishing access automatically from a relationship it owns, for example a roster assignment). A source_kind column, constrained to exactly 'explicit' or 'relationship', distinguishes them at the row level; nothing else about how a grant is stored or evaluated differs by source kind. This lets both kinds share one evaluator code path and one audit trail while still letting HTTP enforcement treat them very differently (see below).

Unscoped grants satisfy scoped or unscoped checks; scoped grants satisfy only the exact matching scope

AuthorizationStore.hasActiveGrant (core/src/authorization/store.ts) matches a grant whose scope column is NULL against any check, scoped or unscoped, but matches a grant with a non-null scope only against a check requesting that exact scope string. An unscoped grant is strictly broader than any scoped grant of the same permission; a scoped grant never leaks into an unscoped check for the same permission. There is no partial, prefix, or hierarchical scope matching.

Revoked and expired grants never authorize

hasActiveGrant's query requires revoked_at IS NULL and either no expires_at or expires_at in the future, evaluated against an injectable clock (now) rather than each caller's own wall-clock read. Revocation is soft: AuthorizationStore.revokeGrant sets revoked_at and the revoking actor rather than deleting the row, so the audit trail and the grant's own history remain queryable after revocation, but a revoked row can never again satisfy an authorization check.

Platform-owner authority is represented separately from grants and is not a wildcard permission

ekklesia_core_platform_owner is its own table, not a synthetic all-permissions grant row. createAuthorizationEvaluator checks platformOwners.isPlatformOwner(userId) as an independent OR alongside hasActiveGrant, so owner status bypasses the grant/scope machinery entirely rather than being implemented as a grant with a special scope or permission value that other code could accidentally match against. This keeps "is a platform owner" and "has this specific permission" structurally distinct, so a future owner-management feature can reason about the one table without 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. 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, core/src/authorization/bootstrap.ts), never an HTTP endpoint. "Bootstrap has already been consumed" is defined as any row ever created in ekklesia_core_platform_owner, for any user, including one whose revoked_at is set — there is no path in this slice that re-opens bootstrap after a revocation; a later, separate owner-management feature (not part of this slice) is how a platform owner would ever be re-established. The single-use check, the target user's existence check, the owner-row insert, and its audit-row insert are one PostgreSQL transaction, serialized by a transaction-scoped advisory lock (pg_advisory_xact_lock, a key pair distinct from the migration runner's own lock) acquired as the transaction's first statement, so two concurrent bootstrap attempts can never both observe "no owner yet" — exactly one succeeds, proven against real PostgreSQL. Any failure path (nonexistent user, lost race, audit failure) rolls back the entire transaction, leaving no partial owner or bootstrap state.

Identity and Authorization use typed Drizzle views over one physical PostgreSQL pool in apps/api

apps/api/src/database.ts's createApiDatabase constructs exactly one validated pg.Pool (core/src/database/pool.ts) and wraps it with two independent typed Drizzle views (createIdentityDrizzleView, createAuthorizationDrizzleView) rather than owning two pools. This is additive: createIdentityDrizzleDatabase and createAuthorizationDrizzleDatabase (each owning a private pool) remain available and unaffected for callers — the bootstrap CLI included — that only need one domain and have no reason to share a connection. apps/api closes the one shared pool exactly once, on graceful shutdown and on any startup failure after the pool was already constructed.

requireTrustedOrigin (apps/api/src/authorization-middleware.ts) requires an exact match against the same trusted-origin set already used for CORS and Better Auth's own checks (ADR 0011, ADR 0012) on every grant-mutation route. This is deliberately a server-side check distinct from CORS: CORS response headers only stop a browser from reading a cross-origin response, not from sending the state-changing request in the first place. A missing or untrusted origin is rejected before the permission check or any state change. Non-browser, non-cookie automation (service accounts/API tokens) has no way to satisfy this check and is explicitly deferred, not silently exempted.

Generic HTTP grant management handles only explicit grants

POST /api/v1/grants always sets source_kind to 'explicit' server-side — the request body has no field that can influence it — and POST /api/v1/grants/:grantId/revoke always filters its mutation to source_kind = 'explicit'. A relationship-derived grant's id, presented to the revoke route, matches zero rows and returns the same 404 a nonexistent grant id would, indistinguishably from the caller's perspective. The generic HTTP API also never accepts actor, sourceKind, sourceRef, any audit field, a grant id, createdAt, or revokedAt in a mutation body: the allowlist rejects the entire request if any other key is present, so a well-formed-looking request cannot smuggle a server-derived field past validation.

Relationship-derived grants may be created or revoked only by the trusted module operation owning that relationship

No code path in this repository creates a source_kind = 'relationship' grant yet — there is no roster or similar module operation in this slice — but the architectural boundary is fixed here: when that operation exists, it is the only trusted caller for that grant's lifecycle, exercised through AuthorizationStore directly (as pass 2's own integration tests do to seed one), never through the generic /api/v1/grants HTTP surface. Nothing about this ADR assumes what that future module operation looks like — only that it does not go through the explicit-grant API.

Deferred

Roles, groups, teams, and tenants; an owner-management HTTP endpoint (revoking or re-granting platform-owner status over HTTP); service accounts and API tokens for non-browser automation; and module permission discovery/registration (a mechanism for a module to contribute its own declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and remain later work.

Update — see ADR 0014. Module permission discovery/registration, deferred above, was implemented by ADR 0014: a module manifest declares permissions in the same PermissionDeclaration shape this ADR's registry uses, and apps/api composes Core's declarations with every installed module's into the one registry every request is enforced against. Roles/groups/teams/tenants, owner-management HTTP, and service accounts/API tokens remain deferred.

Alternatives

  • Representing platform-owner status as a special grant row (a synthetic * permission, or a reserved scope value meaning "everything") was rejected: it would make "is this row an owner grant or a real permission grant" a runtime distinction every future grant-handling code path would need to remember to check, rather than a structural one enforced by table shape.
  • Implementing bootstrap as "check for an existing owner, then call the same grantPlatformOwner function grants already use" was rejected: composing two separately-transacted operations leaves a window between the check and the grant where a concurrent caller could observe the same "no owner yet" state, which is exactly the race a single-use bootstrap must not allow. A dedicated, single-transaction operation closes that window structurally instead of relying on discipline at every call site.
  • Allowing the generic explicit-grant HTTP API to also manage relationship-derived grants (with an optional sourceKind field in the request body) was rejected: it would let any caller holding core.authorization.grants.manage silently unwind access a module relationship established for reasons the generic API has no way to evaluate — for example, revoking a roster-derived grant without the roster module ever knowing its own invariant was violated.
  • A second physical PostgreSQL pool dedicated to authorization in apps/api (mirroring identity's own private-pool factory) was rejected in favor of one shared pool: apps/api is one process with one connection budget, and two independently-sized pools would double configured connection limits for no isolation benefit within a single process.

Consequences

Every HTTP route that needs authorization now goes through one reusable middleware (requirePermission) backed by one trusted registry, rather than each route independently deciding how to check a permission. Establishing a platform owner is a deliberate, auditable, one-time operator action with no ambiguity about who holds it or how it was granted. Future work extending authorization — module-declared permissions, relationship-derived grants, roles — has a fixed shape to extend rather than a green field: new permissions register through the same createPermissionRegistry constructor, new grant sources use the existing source_kind column and audit table, and any new HTTP surface reuses requirePermission/requireTrustedOrigin rather than reimplementing enforcement. No identity behavior, route, or response shape from ADR 0010, 0011, or 0012 changed.