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:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user