From 967c275027b432e0f17faa59bf9ffaad7a2e7f53 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 23 Jul 2026 11:26:47 -0500 Subject: [PATCH] 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. --- README.md | 26 ++- ...-core-authorization-and-owner-bootstrap.md | 209 ++++++++++++++++++ docs/architecture/decisions/README.md | 1 + docs/development/authorization.md | 6 +- docs/development/repository-workflow.md | 3 +- 5 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md diff --git a/README.md b/README.md index 2c6e33b..dfc62b1 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ surfaces. ## Current status -Phase 3 is complete, Phase 4A (Core identity foundation) and Phase 4B (identity HTTP transport) -have landed, and Phase 4C (Core authorization) is in progress. The repository includes +Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), +and Phase 4C (Core authorization) have landed. The repository includes public module contracts, independently versioned capabilities, static module manifests, deterministic automatic discovery, compiled server loading, a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic @@ -40,15 +40,16 @@ production approval-provider composition, jobs, physical storage provisioning, s feature modules, remains later vertical slices. Public registration is not yet ready for a real public launch. -Phase 4C (in progress) adds Core's authorization foundation on top of identity: a schema, -evaluator, and store for platform owners, explicit grants, and relationship-derived grants; a -smallest-possible immutable permission registry; an explicit, single-use, concurrency-safe -platform-owner bootstrap command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never -inferred from signup order); and, in `apps/api`, reusable Hono authorization enforcement plus a -narrowly scoped `/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact -trusted `Origin` on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, -service accounts/API tokens, and module-permission registration remain later work. See -[Authorization](docs/development/authorization.md). +Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store +for platform owners, explicit grants, and relationship-derived grants; a smallest-possible +immutable permission registry; an explicit, single-use, concurrency-safe platform-owner bootstrap +command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup +order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped +`/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin` +on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, service +accounts/API tokens, and module-permission registration remain later work. See +[Authorization](docs/development/authorization.md) and +[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md). ## Quick start @@ -93,8 +94,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 diff --git a/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md new file mode 100644 index 0000000..afcdad4 --- /dev/null +++ b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md @@ -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 +--confirm-user-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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index e257026..49a90cd 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/development/authorization.md b/docs/development/authorization.md index ad7d996..01102ec 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -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 diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 6438197..1d1e266 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -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