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
+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).