Every PostgreSQL connection this repository opened was plaintext, in every environment: no connection site set `ssl` at all, so credentials, session tokens, and row data crossed the network in the clear and nothing in configuration, startup, or `pnpm doctor` said so. The public identity URL boundary was also looser than it appeared. `core/src/database/transport.ts` is now the one typed policy. It has exactly two modes: `verify-full` (TLS required, certificate chain verified, and server identity verified against the configured host — the production default) and `disable` (the development/test default, refused outright in production). There is deliberately no encryption-only `require` mode, no chain-only `verify-ca`, and no `rejectUnauthorized: false` escape hatch anywhere in the configuration surface; every loose or aliased spelling is rejected in both environments, so a typo never quietly becomes a weaker mode. Production has no plaintext exception at all, not even for a loopback database — a connection string cannot prove the database is process-local. Verification is certificate *and* hostname. The policy supplies an explicit `checkServerIdentity` pinned to the host parsed from DATABASE_URL, because the pinned driver sets TLS `servername` only for non-IP hosts and Node performs no identity check when it is unset — so `rejectUnauthorized: true` alone accepts a certificate from any host the trusted CA ever signed when connecting by IP. Both halves are proven against a real TLS-enabled PostgreSQL, including that the same connection without the pinned callback succeeds. Every CA, certificate, and key is generated at test runtime and deleted afterwards. A DATABASE_URL may no longer carry its own TLS parameters. The pinned driver merges parsed connection-string values over explicitly passed options, so a `?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object; that now fails with a stable code, reported by both `pnpm doctor` and the migration CLIs rather than as "failed unexpectedly". One shared environment reader keeps `process.env` out of Core's reusable constructors, and every executable that opens a connection resolves the policy identically: the API (validated before any pool, Better Auth instance, module runtime, or listener exists), both migration commands, the bootstrap CLI, and Doctor's own connection. Doctor reports it as a blocking failure, never a warning, naming only the effective mode. Separately, the identity base URL and every trusted origin must now be exactly one canonical origin — the WHATWG origin serialization must equal the supplied string — so a path, trailing slash, credentials, explicit default port, or non-canonical case is rejected rather than normalized into something that no longer matches what a browser sends. Production's loopback rejection now classifies the address instead of comparing against three literals, covering `.localhost`, all of 127.0.0.0/8, 0.0.0.0, `::`, expanded and IPv4-mapped forms. This is a configuration-shape guarantee only: it does not resolve DNS or prove reachability, and TLS protects the connection, not the exposure. Co-Authored-By: Claude Opus 5 <[email protected]>
551 lines
38 KiB
Markdown
551 lines
38 KiB
Markdown
# Authorization
|
||
|
||
Phase 4C establishes Core's authorization foundation and wires it into `apps/api` over HTTP. Phase
|
||
5A extends that same system so an installed module's declared permissions are enforced and
|
||
grantable through the identical mechanism as Core's own. This page covers all of it: the
|
||
schema/evaluator/store/registry Core owns, the platform-owner bootstrap operation, the
|
||
enforcement/endpoints `apps/api` exposes, and how module manifests contribute to the one platform
|
||
permission registry. See [Identity](identity.md) for the identity foundation this builds on,
|
||
[API](api.md) for the HTTP transport it is mounted into, [Module development](../module-development/README.md)
|
||
for the manifest shape that declares module permissions, and
|
||
[ADR 0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) /
|
||
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md)
|
||
for the durable architectural decisions this page describes in more implementation detail.
|
||
|
||
## What this is, and is not
|
||
|
||
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 for any
|
||
registered permission, Core-owned or module-owned; a module can register its own HTTP routes and
|
||
have them mounted, enforced, and reachable in production (see
|
||
[API](api.md#module-http-routes)); 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); there are no service accounts or API tokens, so only cookie-authenticated
|
||
browser callers can reach mutation routes, Core-owned or module-owned; 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
|
||
|
||
`PermissionDeclaration` (`packages/contracts/src/authorization.ts`) is the one authoritative,
|
||
strict Zod-validated shape of a *registered declaration* — exactly `{id, baseline, description}`,
|
||
nothing more — shared by Core's authorization registry and a module manifest's `permissions` array.
|
||
Core does not declare its own separate interface for this; `core/src/authorization/permissions.ts`
|
||
imports and re-exports the contracts type so existing `@ekklesia/core/authorization` consumers keep
|
||
importing it from the same place. `id` is a branded `PermissionId`, `baseline` is
|
||
`public`/`authenticated`/`restricted`, and `description` is required, trimmed, non-empty operator
|
||
and audit-facing text up to 240 characters — informational only; nothing in authorization reads or
|
||
interprets it.
|
||
|
||
`core/src/authorization/permissions.ts` defines the smallest possible registry on top of that
|
||
shape: an immutable, composition-time-built `PermissionRegistry` distinguishing a *registered*
|
||
declaration 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.
|
||
|
||
Permission matching is always exact string equality, both in the registry and in the evaluator's
|
||
grant lookup — there is no wildcard, prefix, or namespace-inheritance matching anywhere; the
|
||
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
|
||
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
|
||
|
||
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and
|
||
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
|
||
same declarations exported separately in deeply immutable form, for the module composition helper
|
||
below to build on without reconstructing Core's own list:
|
||
|
||
| Permission | Baseline | Description |
|
||
| --- | --- | --- |
|
||
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
||
|
||
## Module permission composition
|
||
|
||
A module manifest's `permissions` array (`manifestVersion: 3`,
|
||
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
||
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
||
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
||
anything. `composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) is the trusted
|
||
composer: given an already-discovered module list (for example `discoverModules`'s result), it
|
||
builds one combined `PermissionRegistry` from `CORE_PERMISSION_DECLARATIONS` followed by every
|
||
module's declarations, in discovery order, using the same `createPermissionRegistry` constructor —
|
||
not a second registry implementation. Declaration order is deterministic: Core first, then each
|
||
module in the order it appears in the supplied list, then each module's own declarations in
|
||
manifest-array order. A duplicate exact permission id anywhere in that combined list fails
|
||
composition the same way a duplicate would fail `createPermissionRegistry` directly — since a
|
||
permission id must start with its own module's id, this is only reachable across two entries that
|
||
share the same module id, which real discovery already rules out (`MODULE_ID_DUPLICATE`);
|
||
composition still re-derives the check rather than trusting that guarantee alone.
|
||
|
||
`core.*` is reserved for Core's own permissions. A discovered module contributing any
|
||
`core.`-prefixed permission id fails composition with `ModulePermissionCompositionError`
|
||
(`MODULE_PERMISSION_RESERVED_NAMESPACE`) before duplicate detection ever runs — since every
|
||
Core-owned id is itself `core.`-prefixed, this is what actually catches a module attempting to
|
||
collide with a Core-owned id specifically, regardless of that module's own id and regardless of
|
||
whether the id happens to exactly match an existing declaration.
|
||
|
||
Composition itself performs no filesystem, environment, database, network, or module-code
|
||
operation — it is pure, synchronous, in-memory composition over data a caller already discovered
|
||
and validated (`core/src/authorization` and `core/src/modules` remain independent of each other in
|
||
the other direction: authorization never imports module discovery or runtime, only the module
|
||
integration layer depends on authorization).
|
||
|
||
## API startup: discovery, composition, and enforcement
|
||
|
||
`apps/api` builds its production permission registry, and starts the module runtime, from the
|
||
exact same installed-module inventory every time it starts, in a fixed order, before anything
|
||
else:
|
||
|
||
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
|
||
platform/Module SDK versions from their own `package.json` files).
|
||
2. Discover installed modules exactly once (`discoverModules`).
|
||
3. Compose the platform permission registry from that exact discovered list
|
||
(`composePlatformPermissionRegistry`).
|
||
4. Construct the shared PostgreSQL pool.
|
||
5. Construct Identity, the authorization store, and the evaluator.
|
||
6. Start the module runtime from that same discovered list (`startDiscoveredModuleRuntime`) —
|
||
modules are never rediscovered for this step.
|
||
7. Create the Hono application, injecting the composed registry and mounting the started module
|
||
runtime's published routes (see [API](api.md#module-http-routes)).
|
||
8. Bind the HTTP server.
|
||
|
||
`apps/api/src/platform-permissions.ts`'s `prepareStartupModuleInventory` performs steps 1–3,
|
||
returning both the discovered module list and the composed registry so step 6 can start the module
|
||
runtime from that identical list rather than rediscovering modules a second time.
|
||
(`composeStartupPermissionRegistry` remains available as a registry-only convenience wrapper
|
||
around it, for callers — Doctor, `modules:validate`, existing tests — that only need the
|
||
registry.) Discovery and composition happen strictly before any pool, Better Auth instance, module
|
||
entrypoint import, or HTTP server exists, so a failure there — an invalid manifest, a broken
|
||
dependency graph, a module claiming the reserved `core.*` namespace, or a duplicate permission
|
||
declaration — leaves nothing to close: it propagates directly out of `startApi()`, and startup
|
||
never falls back to `CORE_PERMISSION_REGISTRY` or any Core-only registry. The repository root is
|
||
resolved from this compiled/source file's own location (two directories up from
|
||
`apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at identical depth), never `process.cwd()`
|
||
— a package-scoped test run has `apps/api` as its working directory, which would otherwise point
|
||
discovery at the wrong `modules/` directory.
|
||
|
||
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
|
||
installation, removal, or dynamic mutation of installed modules or their permissions while the
|
||
process is running — an installed-module change (add, remove, or edit a manifest) takes effect
|
||
only on the next API restart. `createApiApp` requires this registry as part of its injected
|
||
authorization options; `requirePermission` and the explicit-grant routes
|
||
(`GET/POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke`) consult exactly the same
|
||
registry Core's own `core.authorization.grants.manage` permission is registered in, so a
|
||
module-owned permission and Core's own are enforced, and grantable, through the identical
|
||
mechanism — there is no module-specific enforcement path. Removing a module from the next
|
||
startup's discovered inventory makes its permission unknown to new authorization checks and new
|
||
grants (a well-formed but unregistered permission denies exactly as before, including on
|
||
`POST /api/v1/grants`, which checks the registry explicitly before calling the store); it never
|
||
deletes, revokes, or otherwise modifies a historical grant or audit row already recorded for that
|
||
permission. A previously granted, still-unrevoked explicit grant for a now-uninstalled module's
|
||
permission remains visible through `GET /api/v1/grants` and simply stops being something a
|
||
`restricted`-baseline authorization check can newly succeed against. Listing and explicit
|
||
revocation (`POST /api/v1/grants/:grantId/revoke`) are unaffected either way: neither route
|
||
consults the registry for a grant that already exists — revocation is keyed to the grant's own id
|
||
and source kind, not to whether its permission is currently registered — so a manager can still
|
||
revoke a historical grant for a now-unregistered permission.
|
||
|
||
A module's own mounted HTTP routes (`/api/v1/modules/<module-id>/*`) are enforced through this
|
||
identical registry and `requirePermission` middleware — see [API](api.md#module-http-routes) for
|
||
the route-mounting contract. Relationship-derived grant creation remains a deferred, unrelated
|
||
slice — this work only makes a module's *declared* permission usable through Core's existing,
|
||
generic explicit-grant HTTP API, enforcement middleware, and now its own mounted routes.
|
||
|
||
## Doctor and modules:validate
|
||
|
||
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
||
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
||
it never re-discovers modules itself. A successful composition reports a pass with a safe
|
||
declaration count (for example `1 permission registered` with zero installed modules, since Core's
|
||
own declaration is always present); zero installed modules still composes successfully. If module
|
||
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
|
||
pattern; if discovery succeeded but composition failed, schema and migration checks still run
|
||
normally — they are independent diagnostics that do not depend on the permission registry. A
|
||
reserved-namespace violation (`ModulePermissionCompositionError`,
|
||
`MODULE_PERMISSION_RESERVED_NAMESPACE`) and a generic duplicate declaration
|
||
(`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) are reported as distinct
|
||
failure details — a reserved `core.*` claim is never described as an ordinary duplicate, even
|
||
though (see above) a module attempting to exactly collide with a Core-owned id always surfaces as
|
||
the reserved-namespace failure specifically. Composition requires no database and never imports or
|
||
executes a module's server entrypoint. `pnpm modules:validate` composes the same registry from its
|
||
own already-discovered modules for the identical reason: a manifest that is otherwise structurally
|
||
valid but claims the `core.*` namespace, or duplicates a permission id, now fails validation before
|
||
that module is considered installable.
|
||
|
||
### 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 the running API's composed platform registry (Core's own declarations plus every installed module's — see [API startup](#api-startup-discovery-composition-and-enforcement)). 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 three
|
||
independent typed Drizzle views — `createIdentityDrizzleView(pool)`,
|
||
`createAuthorizationDrizzleView(pool)`, and `createModuleDatabaseView(pool)` (the `ModuleDatabase`
|
||
facade module code queries through `context.services.database`; see
|
||
[API](api.md#module-database-access) and
|
||
[Module development](../module-development/README.md#database-access)) — rather than three
|
||
separate pools. `close()` on the returned handle is the only thing that ever calls `pool.end()`;
|
||
no domain view owns or closes the pool itself. `apps/api/src/server.ts` calls `createApiDatabase`
|
||
once during `startApi()`, builds the identity runtime from `.identity`, the
|
||
`AuthorizationStore`/`AuthorizationEvaluator` from `.authorization`, and the frozen
|
||
`ModuleRuntimeServices` from `.module` 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.
|
||
|
||
Both the shared pool and the per-domain factories accept the same optional `transport` policy —
|
||
Ekklesia's single PostgreSQL TLS decision, described in
|
||
[API § Database transport (TLS)](api.md#database-transport-tls). Every executable that opens a
|
||
connection (the API, both migration commands, the platform-owner bootstrap CLI, and `pnpm doctor`)
|
||
resolves it the same way, so production connections are TLS-verified regardless of which factory
|
||
built them. Omitting it is a development/test affordance only: no `ssl` option is set and the
|
||
connection is plaintext, which a production configuration refuses outright.
|
||
|
||
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
|
||
`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and
|
||
module database 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`/`CORE_PERMISSION_DECLARATIONS` contents
|
||
and immutability.
|
||
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
||
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
||
rejection, malformed permission id rejection.
|
||
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 3` acceptance and version
|
||
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
||
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
||
path, and default-empty-list isolation across separate parses.
|
||
- `core/tests/module-permissions.test.ts` — `composePlatformPermissionRegistry`: Core-only and
|
||
Core-plus-modules composition, deterministic ordering, exact baseline/description preservation,
|
||
duplicate rejection, reserved `core.*` namespace rejection, unknown-permission denial once a
|
||
module is absent from the supplied list, and input/output immutability with no I/O.
|
||
- `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;
|
||
a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/
|
||
`restricted` declarations identically to Core's own, that the evaluator receives the registry's
|
||
exact id/baseline, and that an absent module permission denies before principal resolution or
|
||
evaluator access — the shared mechanism, not a module-specific code path.
|
||
- `core/tests/doctor.test.ts` / `core/tests/doctor-report.test.ts` — the `modules.permissions`
|
||
check: passing count (including zero installed modules), reuse of the already-discovered module
|
||
list (no rediscovery), distinct reserved-namespace vs. generic-duplicate failure detail, an
|
||
unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing
|
||
independently after a composition failure, and the exact documented terminal report shape.
|
||
- `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/module-http-routes.test.ts` — the production route-mounting contract: exact
|
||
prefix/path joining (including the non-trailing-slash root case), every supported method,
|
||
independent same-path routes across two modules, deterministic static-versus-parameterized
|
||
resolution by published order, request/handler adaptation (raw `Request`, frozen context, decoded
|
||
and frozen path parameters, empty frozen params for static/root routes), the registry's exact
|
||
baseline for `public`/`authenticated`/`restricted` module permissions, denial/infrastructure-
|
||
failure paths never invoking the handler, `ModuleRouteConfigurationError` for an inconsistent
|
||
route/registry snapshot, trusted-origin enforcement for state-changing methods, CORS (advertised
|
||
methods, no reflection for an untrusted origin, no authorization or handler invocation on
|
||
preflight), fixed sanitized `500` for a throwing/rejecting/non-`Response`-returning handler with
|
||
no raw leakage, a valid module-owned 4xx/5xx response passing through unchanged, forced
|
||
`Cache-Control`/`Vary` with a handler's own `Vary` preserved, stripped module-supplied CORS
|
||
headers, `HEAD` traversing the same permission middleware as `GET` with no body, and unmatched
|
||
paths/zero-route/no-handler-during-construction behavior.
|
||
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above,
|
||
plus module-permission composition at startup: discovery/composition run before any pool,
|
||
Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace
|
||
failure, and a generic duplicate-declaration failure each create zero pools/Better Auth
|
||
instances/servers and remain distinguishable from one another; zero installed modules still
|
||
succeeds; and import safety for `platform-permissions.ts`. A dedicated block covers module
|
||
*runtime* startup and shutdown against real, working (non-throwing) fixture modules: permission
|
||
composition and module runtime startup share the exact same discovered inventory, route mounting
|
||
order matches discovery order, a load/register/start failure each close the pool exactly once and
|
||
bind no server (preserving Core's own rollback for a start failure), an app-construction failure
|
||
and a bind failure both stop the module runtime before closing the pool, normal shutdown stops the
|
||
server/module-runtime/pool in that exact order, a module stop failure still runs pool cleanup
|
||
before the shutdown rejection surfaces, and a direct `discoveredModulesOverride` drives both
|
||
composition and runtime startup identically.
|
||
- `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), 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`.
|
||
- `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live
|
||
PostgreSQL) — a real temporary module fixture (structurally valid manifest; a real, working
|
||
server entrypoint recording register/start/stop markers instead of throwing, and declaring a
|
||
restricted `GET /events/:eventId` route plus a restricted, state-changing `POST /events` route
|
||
against its own manifest permission) proves the complete chain against a real API process and
|
||
real Doctor run: Doctor recognizes the installed module and passes permission composition
|
||
without executing its entrypoint (an empty marker set); a successful `startApi()` discovers the
|
||
same fixture, imports/registers/starts its runtime exactly once, and makes its declared
|
||
permission enforceable; a bootstrapped platform owner creates a real explicit grant for the
|
||
module's permission with an atomic audit row recorded under its exact declared id; the module's
|
||
own mounted route denies an anonymous caller and then serves the newly granted owner with the
|
||
decoded path parameter and authenticated principal; the module's state-changing route requires a
|
||
trusted `Origin` exactly like the platform's own mutation routes; a well-formed permission absent
|
||
from the composed registry is rejected with no grant or audit write; and, after a clean shutdown
|
||
(module stop invoked exactly once) and restart with the module absent, its former route returns
|
||
the ordinary `404`, the same permission can no longer be newly granted, and the earlier,
|
||
still-unrevoked grant remains listed — with ordinary identity HTTP behavior (sign-up, sign-in,
|
||
principal resolution) unaffected throughout.
|
||
|
||
Run the focused suites:
|
||
|
||
```bash
|
||
pnpm --filter @ekklesia/core test
|
||
pnpm --filter @ekklesia/api test
|
||
pnpm test:migrations:integration # requires Docker
|
||
```
|
||
|
||
## Next phase
|
||
|
||
Module HTTP route registration and production mounting (see
|
||
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
|
||
for the deferral this closes) is now implemented — see [API](api.md#module-http-routes) and
|
||
[Module development](module-development/README.md#http-routes). Deliberately deferred, unrelated
|
||
slices remain: a module runtime-context authorization service, relationship-derived grant creation
|
||
itself (a roster or similar module operation, not the generic grants API), reinstall/reactivation
|
||
policy for a permission whose owning module was previously removed, roles/groups/teams/tenants, an
|
||
owner-management HTTP endpoint, and service accounts/API tokens for non-browser automation.
|