Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]>
744 lines
52 KiB
Markdown
744 lines
52 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 user-deletion endpoint, owner-management HTTP endpoint, or owner-recovery
|
||
workflow (revoking or re-granting platform-owner status is not exposed at all, and deleting the last
|
||
owner does not reactivate bootstrap); 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 — plus the Phase 5D pass 3
|
||
integrity rules recorded in
|
||
[ADR 0019](../architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
|
||
|
||
### What user deletion removes, and what it does not
|
||
|
||
Deleting an identity user still cascades to that user's **live** authorization rows: their grants
|
||
(`ekklesia_core_grant`) and their platform-owner row (`ekklesia_core_platform_owner`) are removed,
|
||
so nothing continues authorizing a principal that no longer exists.
|
||
|
||
It does **not** remove security records. `ekklesia_core_authorization_audit.subject_user_id` is a
|
||
retained, non-null historical snapshot with no foreign key to the user table, and
|
||
`ekklesia_core_platform_owner_bootstrap` has no user foreign key at all. Both survive user
|
||
deletion. Previously the audit subject cascaded, so deleting a user destroyed exactly the record of
|
||
what they had been granted, by whom, and when it was revoked.
|
||
|
||
Only opaque identifiers are retained — a subject id and an actor id. No name, email, or other
|
||
profile data is ever copied into an authorization table.
|
||
|
||
**Append-only is an application convention, not a database guarantee.** Core's authorization store
|
||
exposes no update or delete path for audit rows. Nothing in PostgreSQL enforces that: no role,
|
||
trigger, or rule prevents a database administrator, a future migration, or anything else holding
|
||
the same credentials from modifying them. They are not cryptographically immutable.
|
||
|
||
### Deploying these changes
|
||
|
||
All of the above depends on Core migration `0002_authorization_integrity`, which creates
|
||
`ekklesia_core_platform_owner_bootstrap`, drops the cascading audit-subject foreign key, and
|
||
backfills consumed state. **Migrations remain explicit operator work and never run at application
|
||
startup** — apply `0002` with `pnpm migrations:apply` *before* starting the new code, exactly as
|
||
with every other migration. It is classified `{"mode":"none"}` in
|
||
`core/drizzle/meta/_ekklesia.json`: it creates a table, removes one referential constraint
|
||
specifically to *retain* data, and inserts one backfill row; it drops no table, column, or row.
|
||
Migrations `0000_identity_core_tables` and `0001_authorization_core_tables` are byte-identical and
|
||
retain their existing checksums.
|
||
|
||
## 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 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. |
|
||
| `core.modules.ui.read` | `authenticated` | List the installed module UIs a browser host may mount. |
|
||
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
|
||
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
|
||
|
||
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
|
||
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
|
||
reading and changing module enablement
|
||
([ADR 0028](../architecture/decisions/0028-module-operational-lifecycle.md)): the read is not a
|
||
milder version of the write, because the lifecycle view is the only place a *disabled* module, a
|
||
*blocked* module, and the required-dependency edges between them are visible at all, whereas
|
||
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
|
||
inside any module.
|
||
|
||
Enablement never changes how a permission is enforced. A **disabled** module's permissions stay
|
||
registered and grantable — composition spans the whole discovered inventory, not the eligible subset
|
||
— so a grant survives an enable/disable cycle and can be issued ahead of enabling a module. Nothing
|
||
enforces those permissions while the module is disabled, because it mounts no routes at all.
|
||
|
||
## Module permission composition
|
||
|
||
A module manifest's `permissions` array (`manifestVersion: 4`,
|
||
[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 `4 permissions registered` with zero installed modules, since Core's
|
||
own declarations are 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 **the singleton row exists** in `ekklesia_core_platform_owner_bootstrap`
|
||
— a dedicated Core-owned table with `id` pinned by a check constraint to the literal `'singleton'`
|
||
and **no foreign key to any user**. Core never deletes from it.
|
||
|
||
This is deliberately no longer "an owner row exists". That marker cascaded on user deletion, so
|
||
deleting the bootstrapped owner's user row removed the owner row *and* its audit evidence, after
|
||
which the one operation that establishes a platform's first administrator could be run again. The
|
||
operational consequence of the fix is explicit and intended: **deleting the last platform owner
|
||
does not reactivate bootstrap**, and neither does revoking one. Re-establishing an owner after that
|
||
requires a separately designed owner-management flow, which does not exist yet. There is no reset
|
||
command, deletion path, HTTP endpoint, environment override, or force flag.
|
||
|
||
Upgrading an existing installation is handled by Core migration `0002_authorization_integrity`,
|
||
which inserts the singleton if any platform-owner row already exists — active or revoked — so an
|
||
existing deployment cannot gain a fresh first use. It records no `consumed_for_user_id`, because a
|
||
backfill cannot know which of several historical owner rows the original single use targeted, and
|
||
guessing would fabricate history. An installation that never had an owner is left untouched and
|
||
keeps its one first use.
|
||
|
||
One upgrade limitation is irrecoverable: if an operator manually deleted the bootstrapped owner's
|
||
user row *before* this migration, the old cascade already erased both the owner row and its audit
|
||
evidence, and the migration has nothing left to detect — such an installation backfills as
|
||
never-bootstrapped. At the current checkpoint no supported Better Auth or API user-deletion path
|
||
exists that could produce this state; it requires direct database access.
|
||
|
||
The sentinel claim, the target user's existence check, the owner-grant insert, and its audit entry
|
||
are all one PostgreSQL transaction. Consumption is therefore tied to *success*: a missing target
|
||
user, a constraint violation, or any other failure rolls the sentinel back with everything else,
|
||
leaving the one legitimate first use available. 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` | The durable bootstrap-consumed singleton exists; bootstrap has already been used and cannot be used again. |
|
||
| `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>&limit=<1..100>&cursor=<opaque>` | No | One bounded page of the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side on every page). |
|
||
| `POST /api/v1/grants` | Yes | Creates a new **unscoped** explicit grant. |
|
||
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant, and every unrevoked equivalent of it, without deleting anything. |
|
||
|
||
`:grantId` must be the canonical lowercase hyphenated UUID PostgreSQL emits (`8-4-4-4-12` lowercase
|
||
hexadecimal). Uppercase, whitespace-padded, shortened, overlong, and non-UUID values are rejected
|
||
with `400 {"code":"INVALID_REQUEST"}` after authorization but strictly before any store or database
|
||
call — previously such a value reached PostgreSQL's own UUID comparison and returned an
|
||
indistinguishable `503`. A *canonical* id that does not exist still returns the sanitized `404`.
|
||
|
||
### `POST /api/v1/grants` request body
|
||
|
||
Only three keys are accepted; any other key present (`scope`, `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`. |
|
||
| `expiresAt` | No | Exactly `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to the parsed date's own `toISOString()`, and strictly in the future. |
|
||
|
||
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.
|
||
|
||
**The generic HTTP surface creates unscoped explicit grants only.** A `scope` field is rejected in
|
||
every form — including `null`, empty, malformed, and otherwise valid scope strings — with
|
||
`400 {"code":"INVALID_REQUEST"}` before any store mutation. No HTTP route middleware supplies a
|
||
scope to the evaluator today, so a scope accepted here would produce a grant that persists, appears
|
||
in listings, and can authorize nothing. Core's `AuthorizationScope`, the evaluator's exact-scope
|
||
semantics, and the store's scope column are unchanged and remain available to trusted internal
|
||
callers; listing still shows a scope on historical or internally created grants. **Scopes cannot
|
||
authorize a module HTTP route today**, and no route-parameter-to-scope resolver was added.
|
||
|
||
`expiresAt` deliberately accepts one representation and nothing else. Rejected: offsets
|
||
(`2027-01-01T00:00:00.000+02:00`), missing milliseconds (`2027-01-01T00:00:00Z`), excess fractional
|
||
precision, lowercase separators, whitespace padding, date-only (`2027-01-01`), locale forms
|
||
(`March 5, 2027`), extra trailing text, normalized impossible dates (`2026-02-31T00:00:00.000Z`,
|
||
which bare `new Date()` silently rewrote to March 3rd), and any instant at or before the acceptance
|
||
clock. Core's store independently rejects an invalid or non-future `Date` before any SQL, so a
|
||
trusted internal caller cannot create a dead-on-arrival grant either. A rejected expiration creates
|
||
no grant and no audit row. There is no maximum lifetime, renewal workflow, scheduler, or background
|
||
expiration job.
|
||
|
||
### Duplicate creation and all-equivalent revocation
|
||
|
||
Two grant issuances are the **same authorization source** when all five of these are equal, with
|
||
`NULL` equal only to `NULL`: `user_id`, `permission_id`, `scope`, `source_kind`, `source_ref`.
|
||
`expires_at`, creation metadata, actor metadata, and the grant id do not distinguish them.
|
||
|
||
Creating a grant while an equivalent one is *currently effective* (`revoked_at IS NULL` and
|
||
`expires_at IS NULL OR expires_at > now()`) fails with
|
||
`409 {"code":"AUTHORIZATION_GRANT_ALREADY_EXISTS"}` and `Cache-Control: no-store`. The response
|
||
names no existing grant id, actor, scope, source reference, expiry, database detail, SQL, or stack —
|
||
only the code. A rejected duplicate creates no grant and no audit row. A revoked or already-expired
|
||
equivalent does not block a fresh issuance. This is a conflict rather than an idempotent return of
|
||
the existing grant: the caller asked to record a new authorization with their own actor and expiry,
|
||
and answering "done" while recording neither would hide that nothing happened.
|
||
|
||
Enforcement is database-backed, not process-local: every create and revoke touching one equivalence
|
||
tuple takes a transaction-scoped `pg_advisory_xact_lock` keyed by a hash of that tuple, so multiple
|
||
API processes serialize identically. Its single-key lock space is disjoint from the two-key space
|
||
used by the migration runner and platform-owner bootstrap.
|
||
|
||
**One revocation revokes every unrevoked equivalent**, including expired-but-unrevoked legacy rows,
|
||
in one transaction, writing one `grant.revoke` audit row per grant actually revoked, each carrying
|
||
that row's own grant id. Grants with a different subject, permission, scope, source kind, or source
|
||
reference are untouched. This replaces behavior where revoking one of two equivalent grants returned
|
||
`success` while the other kept authorizing the same user. The public success response remains
|
||
`200 {"success": true}`.
|
||
|
||
Historical duplicates are **not** silently revoked by any migration. The store now prevents creating
|
||
another effective equivalent, and an operator's explicit revocation resolves the whole equivalence
|
||
class.
|
||
|
||
### 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
|
||
}
|
||
```
|
||
|
||
`POST /api/v1/grants` → `201 {"grant": {...}}`.
|
||
`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`.
|
||
|
||
`GET /api/v1/grants` → `200 {"grants": [...], "nextCursor": <string|null>}`.
|
||
|
||
### Listing is bounded keyset pagination
|
||
|
||
```text
|
||
GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>
|
||
```
|
||
|
||
| Parameter | Required | Notes |
|
||
| --- | --- | --- |
|
||
| `userId` | Yes | The subject whose explicit grants to list. |
|
||
| `limit` | No | Canonical base-10 integer, 1 through 100. Defaults to 50. |
|
||
| `cursor` | No | An opaque position string returned as a previous page's `nextCursor`. |
|
||
|
||
Unknown query keys, duplicated `userId`/`limit`/`cursor` keys, empty values, non-canonical numbers
|
||
(`0`, `101`, `-1`, `+5`, `01`, `1.5`, `1e2`, whitespace-padded), and malformed cursors are all
|
||
rejected with `400 {"code":"INVALID_REQUEST"}` before any store or database work. Permission
|
||
enforcement completes first, so query validation never discloses a validation distinction — or the
|
||
existence of any subject or grant — to an unauthorized caller.
|
||
|
||
Rows are ordered `created_at DESC, id DESC`, with a strict keyset boundary on **both** values, so
|
||
grants sharing a `created_at` can neither be skipped nor repeated across pages. At most `limit + 1`
|
||
rows are read internally and at most `limit` returned. The final page returns `"nextCursor": null`.
|
||
Relationship grants never appear on any page. There is no total count, offset pagination, search, or
|
||
sorting option.
|
||
|
||
The cursor is a small versioned payload containing only the subject user id, boundary creation
|
||
timestamp, and boundary grant id, encoded as canonical unpadded base64url. Decoding is strict —
|
||
exact version, exact keys, a subject matching the *requested* one, a canonical `toISOString()`
|
||
timestamp, a canonical `GrantId`, and a re-encoding that reproduces the supplied string
|
||
byte-for-byte — and a rejected cursor reveals nothing it decoded.
|
||
|
||
**It is a pagination position, not an authorization token.** It is deliberately unsigned: the
|
||
permission check and the server-side subject filter remain authoritative on every page, so a forged
|
||
cursor can at most name a different position within grants the caller may already list. It carries
|
||
no permission, scope, source reference, actor data, credential, or secret, and it is not a
|
||
consistency snapshot — rows created or revoked between pages are seen or not seen according to the
|
||
ordering.
|
||
|
||
### 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 canonical grant id does not exist as an unrevoked *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. |
|
||
| `409 {"code":"AUTHORIZATION_GRANT_ALREADY_EXISTS"}` | An equivalent grant is already currently effective for the subject. |
|
||
| `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: 4` 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. Pass-3 scenarios add: exactly
|
||
one consumed row plus one owner row plus one audit row in one transaction; deleting the owner's
|
||
user row keeping the sentinel and the audit history while removing the live owner row and not
|
||
reactivating bootstrap; a grant subject's create/revoke audit rows surviving that subject's
|
||
deletion while their live grant cascades; a forced failure after the sentinel was claimed rolling
|
||
everything back so the one first use is not burned; and migration `0002` backfilling consumed
|
||
state for an active *and* a revoked existing owner while leaving a never-owned installation with
|
||
exactly one first use.
|
||
- `core/tests/authorization-store.integration.ts` (live PostgreSQL) — pass-3 grant-integrity
|
||
scenarios: sequential and six-way concurrent equivalent creation yielding exactly one grant and
|
||
one create audit row; a differing expiry not bypassing the conflict; different subject/permission/
|
||
scope/source-kind/source-ref being non-equivalent; fresh issuance after a revoked or expired
|
||
equivalent; one revocation clearing every unrevoked equivalent (including an expired-but-unrevoked
|
||
one) with per-row audit entries while leaving differently scoped or sourced grants untouched; a
|
||
concurrent create/revoke race serializing without an unexplained duplicate; a forced transaction
|
||
failure rolling back every grant and audit change; and bounded keyset pagination traversing seven
|
||
rows that all share one `created_at` with no duplicates or omissions.
|
||
- `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 (including every form of a `scope` field), canonical-`GrantId` and
|
||
canonical-future-`expiresAt` rejection with the store never called, the sanitized `409` conflict,
|
||
the full pagination contract (default/boundary limits, non-canonical limit rejection, unknown and
|
||
duplicated query keys, multi-page traversal, and every malformed or foreign cursor),
|
||
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.
|