docs: record core authorization architecture

Adds ADR 0013 documenting the durable Phase 4C decisions (permission
registry, evaluator baselines, grant/scope semantics, platform-owner
bootstrap, shared database pool, Origin enforcement, and what remains
deferred), cross-links it from the authorization/README docs, and
refreshes the pnpm doctor no-DATABASE_URL example output in README.md
and repository-workflow.md to include the authorization migration.
This commit is contained in:
ekklesia
2026-07-23 11:26:47 -05:00
parent 1363608552
commit 967c275027
5 changed files with 230 additions and 15 deletions
@@ -0,0 +1,209 @@
# 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.
### 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. The registry itself
(`PermissionRegistry.get`/`.has`) returns `undefined`/`false` for anything malformed or
unregistered. `requirePermission` treats a registry miss as an immediate sanitized `403`, before
principal resolution or evaluation ever runs. Separately, `createAuthorizationEvaluator` itself
denies (without throwing) any malformed or unrecognized `permission`/`baseline`/`scope` it
receives, so a future caller that reaches the evaluator directly — bypassing the registry, for
example a different transport Core does not yet have — inherits the same deny-by-default guarantee
rather than depending on the registry check having already run.
### 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
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.
### Cookie-authenticated authorization mutations require an exact trusted Origin
`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.
## 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.
+1
View File
@@ -21,3 +21,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted
- [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted
- [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
+4 -2
View File
@@ -3,8 +3,10 @@
Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into
`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the
platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it.
See [Identity](identity.md) for the identity foundation this builds on and [API](api.md) for the
HTTP transport it is mounted into.
See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the
HTTP transport it is mounted into, and [ADR
0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable
architectural decisions this page describes in more implementation detail.
## What this is, and is not
+2 -1
View File
@@ -106,8 +106,9 @@ Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
1 migration · checksums valid
2 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
PostgreSQL
○ Connection check skipped