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.
17 KiB
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 for the identity foundation this builds on and API 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.
createPermissionRegistrythrowsPermissionRegistryError(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)andregistry.has(permissionId)both re-validate their input (safeParsePermissionIdinternally) and returnundefined/falsefor 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:
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:
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:
- Looks up
permissionIdin the trusted, server-sidePermissionRegistry(never a client-supplied baseline) — an unregistered permission id fails closed with403before 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. - Resolves the principal exclusively through the same server-side identity runtime
/api/v1/principalalready uses (EkklesiaIdentityRuntime.resolvePrincipal) — nothing here ever trusts a client-supplied claim about identity. - Calls the injected
AuthorizationEvaluatorwith the registry's baseline and permission for that principal (platform owners pass through the evaluator's owner bypass here — this is how delegation tocore.authorization.grants.managerecipients also works, without a second code path). - On success, stores the resolved
EkklesiaPrincipal(Core's narrow{kind, userId}— never a Better Auth session/user object) on the Hono context viac.set, readable by the route throughgetAuthorizedPrincipal(c). This is a deliberately narrow exception to the "no generalized session/user object in Hono context" concern ADR 0011 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) — 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:
{
"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).
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_REGISTRYcontents.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,PlatformOwnerBootstrapErrorbehavior.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 forrequirePermission, andrequireTrustedOrigin'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 delegatingcore.authorization.grants.manageto 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 a503rather than a false403or200.
Run the focused suites:
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).