Ekklesia's module model has been backend-only since ADR 0001: a module owns domain behavior and data, and every presentation concern belongs to a surface. That prevents a module author — the person who knows the domain — from shipping the screen that makes their module useful, and it guarantees a module and its own UI drift apart. It also pushes toward two codebases for one domain the moment a module needs to run standalone. ADR 0021 replaces that rule. A module is an independently versioned product that owns its domain behavior, data, permissions, routes, migrations, and its own UI, hostable inside Ekklesia and — from the same implementation — by a later standalone host. Core keeps the containing application shell and every platform policy: authentication, authorization enforcement, platform context, top-level routing, navigation, global theme, error boundaries, and trusted-origin/CORS. The module graph stays flat: modules are peers, genuine module and capability dependencies remain the only relationships, and Dashboard folders are presentation configuration an administrator can reorganize, never module architecture. This pass implements the static contract only. `manifestVersion` moves to 4 and `entrypoints` gains one optional field, `ui`. Version 3's `entrypoints` is strict and rejects `ui`, so this is an incompatible shape change and version 3 is now rejected outright rather than reinterpreted, exactly as version 1 was by version 2 and version 2 by version 3. Every field version 4 adds is optional, so upgrading a backend-only manifest is a one-character edit. Discovery resolves and validates `ui` under the same rules as `server` and `schema` — package-relative, inside the package before and after symlink resolution, a real file, compiled ESM `.js`/`.mjs`, so source TypeScript is rejected rather than assumed to be compiled later. Discovery stays inert: nothing imports, evaluates, mounts, or renders a UI entrypoint, and a test proves a throwing artifact still discovers successfully. This follows `entrypoints.jobs`, declared and resolved since ADR 0006 with no runtime consumer. The name is `ui`, not `dashboard`, because a module's UI must be hostable by Ekklesia's shell and by a standalone host, so the contract cannot be named after one of them; and not `surface`, because `surfaces/*` is a separate ownership layer. No widget, route, icon, folder, menu, layout, or standalone-metadata field is added, and tests assert the manifest still rejects every unknown entrypoint key and offers no hierarchy field. Deliberately unchanged: `scripts/check-boundaries.ts` still enforces `modules-do-not-own-ui`. Relaxing that guard depends entirely on the UI authoring model a later pass chooses, and weakening a working protection before that decision exists would trade real coverage for nothing. A module may therefore declare `ui` today only for a framework-neutral compiled artifact, and the docs say so rather than implying otherwise. Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model are untouched — a module that ships UI is exactly as trusted as one that does not. No Dashboard shell, UI runtime, renderer, framework choice, navigation registry, grouping system, standalone host, installer, hot-loading, or feature module is added by this pass. Co-Authored-By: Claude Opus 5 <[email protected]>
727 lines
50 KiB
Markdown
727 lines
50 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, 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: 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 `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 **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.
|