feat: add authorization bootstrap and HTTP enforcement

Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
ekklesia
2026-07-23 11:16:32 -05:00
parent 72e082ff2f
commit 1363608552
33 changed files with 2962 additions and 63 deletions
+30 -19
View File
@@ -9,6 +9,8 @@ A follow-up corrective slice hardened this transport (sanitized Better Auth logg
production-equivalent origin/CSRF protection under the test runner, bounded identity database
timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner
bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
## What this is, and is not
@@ -54,6 +56,7 @@ No real values are shown here — configure per environment and never commit the
| --- | --- | --- |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
deterministic `404 {"code":"NOT_FOUND"}`.
@@ -101,11 +104,12 @@ exist or treats an empty database as special. Baseline authenticated status is w
let a signed-in person use ownership-scoped personal features (watch history, continue watching,
saved content, preferences) — none of those features are implemented in this PR.
Explicit **platform-owner bootstrap is deferred to Phase 4C**, which must introduce an explicit,
auditable operator command that grants an *existing* identity platform-owner authority — signup
order is never part of that decision. Future **relationship-derived access** (for example, a
person added to a volunteer roster automatically gaining narrow, reversible read access to that
roster) belongs to a later roster slice, not this one.
Explicit **platform-owner bootstrap** is now available as a local operator command — see
[Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity
platform-owner authority; signup order is never part of that decision. Future
**relationship-derived access** (for example, a person added to a volunteer roster automatically
gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this
one.
`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not**
automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field
@@ -204,15 +208,19 @@ abuse-control design.
## Database connection limits
`apps/api/src/server.ts` configures the identity PostgreSQL pool (`createIdentityDrizzleDatabase`,
`core/src/identity/database.ts`) with both a connection timeout and an operation timeout (10
seconds each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts`
already uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does
nothing once a query is already running on one. The operation timeout additionally sets `pg`'s
`query_timeout` and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than
holding a pool connection (and its caller) indefinitely. An invalid timeout (non-integer, zero,
negative, or larger than PostgreSQL's own maximum) is rejected as configuration error before any
pool is constructed, sanitized the same way an invalid connection string already is.
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
`apps/api/src/database.ts`, built on `createValidatedPostgresPool` from
`core/src/database/pool.ts`) with both a connection timeout and an operation timeout (10 seconds
each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` already
uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does nothing once
a query is already running on one. The operation timeout additionally sets `pg`'s `query_timeout`
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
constructed, sanitized the same way an invalid connection string already is. Both the identity and
authorization domains read from this one pool through independent typed Drizzle views — see
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
not two.
## Graceful shutdown
@@ -221,7 +229,9 @@ executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and
lets in-flight requests finish, then closes the PostgreSQL pool. The executable entry point's
lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and
authorization both read from it — see
[Authorization](authorization.md#shared-database-composition)). The executable entry point's
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the
same function production composition uses, but independently testable with a fake `running`/`exit`
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real
@@ -268,7 +278,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
## Next phase
Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants /
platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible
relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model
that does not exist yet.
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
Dashboard Surface, which still depends on an authorization model that is only now getting started.
+299
View File
@@ -0,0 +1,299 @@
# Authorization
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.
## What this is, and is not
After this slice, an operator can bootstrap exactly one platform owner for a deployment, a
platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization
grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of
silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants;
there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not
exposed at all in this slice); there are no service accounts or API tokens, so only
cookie-authenticated browser callers can reach the mutation routes; and relationship-derived
grants (for example, automatic access from a roster assignment) cannot yet be created by anything
in this repository — the generic grants API structurally cannot create or revoke them either.
## Core authorization schema and store
Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner`,
`ekklesia_core_grant`, `ekklesia_core_authorization_audit`; `AuthorizationEvaluator`,
`AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope`
branded types). This page does not restate that design — read `core/src/authorization/schema.ts`,
`evaluator.ts`, and `store.ts` directly for it. This page covers what pass 2 added on top: the
permission registry, the bootstrap operation, and HTTP enforcement.
## Permission registry
`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable,
composition-time-built `PermissionRegistry` distinguishing a *registered declaration*
(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties
matter:
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
is declared twice — this can only happen while wiring the registry itself, not at request time.
- **Unknown permissions fail closed at lookup time.** `registry.get(permissionId)` and
`registry.has(permissionId)` both re-validate their input (`safeParsePermissionId` internally)
and return `undefined`/`false` for anything malformed or simply not registered — there is no
"trust this permission id because it looks right" path.
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission:
| Permission | Baseline | Description |
| --- | --- | --- |
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
Module-owned permission declarations and a discovery/composition path for them are out of scope
for this slice (deferred to the Module SDK work).
## Platform-owner bootstrap
There is no "first user becomes owner" inference anywhere in this repository, and no HTTP
endpoint exposes bootstrap. The only way to establish a platform owner is the local CLI command:
```bash
pnpm migrations:apply # the database must already be migrated
DATABASE_URL=postgresql://... pnpm authorization:bootstrap-owner -- \
--user-id <existing-ekklesia-user-id> \
--confirm-user-id <same-id>
```
`DATABASE_URL` is read only when the command actually executes (never merely by importing the CLI
module — proved by `core/tests/cli-bootstrap-platform-owner.test.ts`), and it is never printed,
logged, or included in any error. `--user-id` and `--confirm-user-id` must match exactly, checked
before the id is even parsed as a valid `EkklesiaUserId` — this command never infers a target from
signup order or "the only user in the database"; the operator must name the exact, opaque id
twice.
### Bootstrap is permanently single-use
`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated,
Hono-independent Core operation — deliberately *not* a composition of "check for an owner, then
call the existing grant function." "Already consumed" means **any row has ever existed** in
`ekklesia_core_platform_owner`, for any user, including one whose `revoked_at` is set. There is no
owner-management flow in this slice that clears that state; a revoked owner staying revoked (and
bootstrap staying consumed) is intentional here.
The single-use check, the target user's existence check, the owner-grant insert, and its audit
entry are all one PostgreSQL transaction. The very first statement inside that transaction is a
transaction-scoped advisory lock:
```sql
select pg_advisory_xact_lock(1500000001, 1500000002)
```
This lock key pair is distinct from the migration runner's own advisory lock
(`core/src/migrations/postgres-database.ts`), so bootstrap serialization can never collide with or
be blocked by unrelated migration locking. `pg_advisory_xact_lock` releases automatically on
commit or rollback, so no manual unlock path is needed even when the transaction fails partway.
Two concurrent bootstrap attempts serialize on this lock: the second caller blocks until the
first's transaction commits or rolls back, then re-evaluates the "does any owner row exist" check
against now-current state — so exactly one of two concurrent attempts ever succeeds, proved live
against real PostgreSQL in `core/tests/authorization-bootstrap.integration.ts`
(`Promise.allSettled` on two simultaneous calls; asserts exactly one `fulfilled`, exactly one
`rejected` with `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED`, exactly one owner row, exactly one
audit row).
A failed target-user lookup, an audit-insert failure, or losing the concurrency race all roll back
the entire transaction — there is no partial state where an owner row exists without its audit
entry, or vice versa.
The audit entry for a bootstrap grant always uses a fixed, sanitized, non-user actor identity —
never derived from any caller input — so an auditor can tell a given `platform_owner.grant` row
came specifically from this command rather than from a future owner-management flow:
| Field | Value |
| --- | --- |
| `actor_type` | `system` |
| `actor_id` | `authorization-bootstrap-cli` |
### Error codes
`PlatformOwnerBootstrapError` never carries the underlying database error, SQL text, host, port,
path, or stack — only a stable `code` and a fixed message:
| Code | Meaning |
| --- | --- |
| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | A platform-owner row already exists (any user, revoked or not). |
| `PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. |
| `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). |
## HTTP authorization enforcement
`apps/api/src/authorization-middleware.ts` exports `requirePermission(options, permissionId)`, a
Hono middleware factory reused by every protected route:
1. Looks up `permissionId` in the trusted, server-side `PermissionRegistry` (never a
client-supplied baseline) — an unregistered permission id fails closed with `403` before the
principal is even resolved. In today's wiring only hard-coded registered constants are ever
passed by route code, but the check stays live per-request as defense in depth rather than
being trusted once at composition time.
2. Resolves the principal exclusively through the same server-side identity runtime
`/api/v1/principal` already uses (`EkklesiaIdentityRuntime.resolvePrincipal`) — nothing here
ever trusts a client-supplied claim about identity.
3. Calls the injected `AuthorizationEvaluator` with the registry's baseline and permission for
that principal (platform owners pass through the evaluator's owner bypass here — this is how
delegation to `core.authorization.grants.manage` recipients also works, without a second code
path).
4. On success, stores the resolved `EkklesiaPrincipal` (Core's narrow `{kind, userId}` — never a
Better Auth session/user object) on the Hono context via `c.set`, readable by the route through
`getAuthorizedPrincipal(c)`. This is a deliberately narrow exception to the "no generalized
session/user object in Hono context" concern [ADR
0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) raised —
only this already-public-safe principal shape is ever set.
A denied decision and an unregistered permission id both return a sanitized `403
{"code":"AUTHORIZATION_DENIED"}`. A genuine infrastructure failure — principal resolution,
evaluation, or the database underneath either — is never folded into that `403`; it returns a
sanitized `503` instead (`PRINCIPAL_RESOLUTION_FAILED` or `AUTHORIZATION_EVALUATION_FAILED`),
mirroring `/api/v1/principal`'s existing rule that "could not be determined" must never look like
"no permission." Neither response ever reveals whether a grant or owner row exists for the caller.
### Origin enforcement for mutations
`requireTrustedOrigin(trustedOrigins)` requires an exact match against the same
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` set the API already uses for CORS and Better Auth's own
checks (see [API](api.md#origin-cors-and-cookies)) — a missing or untrusted `Origin` header is
rejected with `403 {"code":"UNTRUSTED_ORIGIN"}` before the permission check or the route handler
ever runs. This exists because CORS response headers alone are not CSRF protection: they only
stop a browser from *reading* a cross-origin response, not from *sending* the state-changing
request in the first place. Every grants mutation route (`POST /api/v1/grants`,
`POST /api/v1/grants/:grantId/revoke`) requires this; the read-only list route does not.
Non-browser, non-cookie automation (service accounts/API tokens) is out of scope for this slice
and has no way to satisfy this check yet.
## Explicit-grant HTTP API
Mounted at the API's root by `apps/api/src/grants-routes.ts`. Every route requires
`core.authorization.grants.manage`. All three routes only ever act on *explicit* grants — the
generic HTTP API structurally cannot create or revoke a relationship-derived grant.
| Route | Origin required | Notes |
| --- | --- | --- |
| `GET /api/v1/grants?userId=<id>` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). |
| `POST /api/v1/grants` | Yes | Creates a new explicit grant. |
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. |
### `POST /api/v1/grants` request body
Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`,
`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or
anything else) makes the **entire body** invalid rather than being silently dropped, so a caller
cannot smuggle a server-derived field through a request that otherwise looks well-formed:
| Field | Required | Notes |
| --- | --- | --- |
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
| `permission` | Yes | Must parse as a valid `PermissionId`. |
| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. |
| `expiresAt` | No | An ISO date string if present. |
The server always derives, and the request body can never supply: the grant id, `sourceKind`
(always `"explicit"`), the actor (always the authenticated caller, via
`createUserActor(principal.userId)`), and all audit/timestamp fields.
### Response shapes
A grant, wherever returned:
```json
{
"id": "...",
"userId": "...",
"permission": "core.authorization.grants.manage",
"scope": null,
"sourceKind": "explicit",
"createdAt": "2026-07-23T00:00:00.000Z",
"expiresAt": null,
"revokedAt": null
}
```
`GET /api/v1/grants` → `200 {"grants": [...]}`. `POST /api/v1/grants` → `201 {"grant": {...}}`.
`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`.
### Status-code mapping
| Status | When |
| --- | --- |
| `200` / `201` | Success. |
| `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). |
| `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`, or the permission id was unregistered. |
| `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. |
| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. |
| `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). |
Every response sets `Cache-Control: no-store`.
## Shared database composition
`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api`
touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown,
and on any startup failure after the pool was already constructed (mirroring the existing
identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
The existing per-domain factories (`createIdentityDrizzleDatabase`,
`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are
unaffected and remain available for standalone/testable use — the CLI bootstrap command uses
`createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs
the authorization schema and has no reason to share a pool with anything else.
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization
usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers
there are or whether the failure happened during startup.
## Tests
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY` contents.
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
per-domain database tests).
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`
behavior.
- `core/tests/cli-bootstrap-platform-owner.test.ts` — the CLI never constructs a pool merely by
being imported.
- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
still blocking reuse, and the concurrency scenario described above.
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior.
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
and malformed-field rejection, exact server-derived-argument assertions, sourceKind-filtered
revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping.
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above.
- `apps/api/tests/api-http.integration.ts` (live PostgreSQL) — end-to-end: anonymous/non-owner
denial, real bootstrap against the running API's own database, owner authority over the HTTP
grants API, the owner delegating `core.authorization.grants.manage` to another account and that
account then managing grants as a non-owner, a create/list/revoke cycle with audit-trail
verification, disallowed-field rejection, missing/untrusted-origin rejection, a real
relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an
infrastructure-failure scenario (database dropped out from under a live session) asserting a
`503` rather than a false `403` or `200`.
Run the focused suites:
```bash
pnpm --filter @ekklesia/core test
pnpm --filter @ekklesia/api test
pnpm test:migrations:integration # requires Docker
```
## Next phase
Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission
registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service
accounts/API tokens for non-browser automation, and relationship-derived grant creation itself
(a roster or similar module operation, not the generic grants API).