Two related fail-closed gaps let a well-formed but never-declared permission id slip through: - POST /api/v1/grants created explicit grants for any well-formed PermissionId without checking the injected permission registry, allowing dormant grants for invented identifiers that could become effective the moment those identifiers were registered later. - createAuthorizationEvaluator validated the permission id only inside the restricted-baseline branch, so a malformed permission combined with the public or authenticated baseline could still return allowed. Both now fail closed: the grants route checks the target permission against the trusted registry before calling store.createGrant (sanitized 403, no store or audit write), and the evaluator validates permission shape before any baseline is allowed to return an allowed decision, without adding a registry dependency to the evaluator itself. The live integration lifecycle test switched from an unregistered calendar.events.read to the registered core.authorization.grants.manage permission, since module permission registration does not exist yet.
337 lines
20 KiB
Markdown
337 lines
20 KiB
Markdown
# 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).
|
|
|
|
### Two layers, not one: shape versus registration
|
|
|
|
`createAuthorizationEvaluator` (`core/src/authorization/evaluator.ts`) has no registry and is
|
|
never given one — it independently re-validates `permission`, `baseline`, and `scope` as a pure
|
|
shape check (`safeParsePermissionId`/`permissionBaselineSchema`/`safeParseAuthorizationScope`) and
|
|
denies a malformed value under **every** baseline, including `public` and `authenticated`, before
|
|
any baseline is allowed to return an allowed decision. This is defense in depth for a future
|
|
caller that reaches the evaluator directly, not a substitute for the registry: the evaluator has
|
|
no way to tell a well-formed-but-unregistered permission id (for example a plausible-looking
|
|
`calendar.events.read` that nothing has ever declared) from a genuinely registered one — it can
|
|
only reject what is not even shaped like a `PermissionId` at all.
|
|
|
|
*Registration* — "does this permission actually exist" — is exclusively the registry's
|
|
responsibility, consulted by every trusted caller before the evaluator ever runs:
|
|
`requirePermission` (`apps/api/src/authorization-middleware.ts`) looks up the *caller's own*
|
|
required permission (for example `core.authorization.grants.manage`, always a hardcoded route
|
|
constant, never client-supplied) in the registry before resolving a principal or evaluating
|
|
anything; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately looks up the *target*
|
|
permission being granted — the one named in the request body — in the same registry, after body
|
|
shape-validation and before ever calling `store.createGrant`, so a caller cannot create a dormant
|
|
grant for an invented permission id that might later become effective the moment someone actually
|
|
registers it. A malformed target permission (fails `PermissionId` shape validation) still resolves
|
|
to the body-level `400 {"code":"INVALID_REQUEST"}` documented below, unchanged; a well-formed but
|
|
unregistered target permission instead receives the identical sanitized
|
|
`403 {"code":"AUTHORIZATION_DENIED"}` a genuinely denied caller would, so that response never
|
|
reveals whether the caller was denied or the permission was simply never registered.
|
|
|
|
## 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` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. |
|
|
| `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`; the caller's own required permission id was unregistered; or, for `POST /api/v1/grants`, the well-formed *target* permission named in the body is not registered. |
|
|
| `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, a well-formed but unregistered target permission rejected with
|
|
`403` and `store.createGrant` never called, 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.
|
|
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
|
|
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
|
consulting the grant or platform-owner ports for `public`/`authenticated`.
|
|
- `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 well-formed but unregistered target permission
|
|
rejected with `403` (with no grant or audit row written) even for a caller with full
|
|
grant-management authority, a create/list/revoke cycle with audit-trail verification (using
|
|
`core.authorization.grants.manage` itself as the granted permission, since module permission
|
|
registration does not exist yet), 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).
|