# 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, [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 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 \ --confirm-user-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=` | 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).