Establishes the one canonical PermissionDeclaration contract (packages/contracts/src/authorization.ts) — id/baseline/description, strict, unknown-property-rejecting — consumed by both Core's authorization registry and module manifests, replacing Core's previously separate interface. Bumps the canonical module manifest to manifestVersion 3: permissions become complete declarations instead of bare identifier strings, with manifest-owned namespace and duplicate-id enforcement. Version 2 and bare string-array permissions are rejected outright; there is no compatibility adapter. Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts), a pure, synchronous composer that builds one platform permission registry from Core's own declarations plus every already-discovered module's, in deterministic order, reusing createPermissionRegistry and rejecting any module-contributed permission in the reserved core.* namespace. core/src/authorization remains independent of core/src/modules; only the module integration layer depends on authorization. Updates the module template, module-development docs, authorization docs, and existing manifest fixtures for manifestVersion 3. Does not wire the combined registry into apps/api — that is pass 2.
396 lines
25 KiB
Markdown
396 lines
25 KiB
Markdown
# Authorization
|
|
|
|
Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into
|
|
`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the
|
|
platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it.
|
|
See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the
|
|
HTTP transport it is mounted into, and [ADR
|
|
0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable
|
|
architectural decisions this page describes in more implementation detail.
|
|
|
|
## What this is, and is not
|
|
|
|
After this slice, an operator can bootstrap exactly one platform owner for a deployment, a
|
|
platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization
|
|
grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of
|
|
silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants;
|
|
there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not
|
|
exposed at all in this slice); there are no service accounts or API tokens, so only
|
|
cookie-authenticated browser callers can reach the mutation routes; and relationship-derived
|
|
grants (for example, automatic access from a roster assignment) cannot yet be created by anything
|
|
in this repository — the generic grants API structurally cannot create or revoke them either.
|
|
|
|
## Core authorization schema and store
|
|
|
|
Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner`,
|
|
`ekklesia_core_grant`, `ekklesia_core_authorization_audit`; `AuthorizationEvaluator`,
|
|
`AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope`
|
|
branded types). This page does not restate that design — read `core/src/authorization/schema.ts`,
|
|
`evaluator.ts`, and `store.ts` directly for it. This page covers what pass 2 added on top: the
|
|
permission registry, the bootstrap operation, and HTTP enforcement.
|
|
|
|
## Permission registry
|
|
|
|
`PermissionDeclaration` (`packages/contracts/src/authorization.ts`) is the one authoritative,
|
|
strict Zod-validated shape of a *registered declaration* — exactly `{id, baseline, description}`,
|
|
nothing more — shared by Core's authorization registry and a module manifest's `permissions` array.
|
|
Core does not declare its own separate interface for this; `core/src/authorization/permissions.ts`
|
|
imports and re-exports the contracts type so existing `@ekklesia/core/authorization` consumers keep
|
|
importing it from the same place. `id` is a branded `PermissionId`, `baseline` is
|
|
`public`/`authenticated`/`restricted`, and `description` is required, trimmed, non-empty operator
|
|
and audit-facing text up to 240 characters — informational only; nothing in authorization reads or
|
|
interprets it.
|
|
|
|
`core/src/authorization/permissions.ts` defines the smallest possible registry on top of that
|
|
shape: an immutable, composition-time-built `PermissionRegistry` distinguishing a *registered*
|
|
declaration from a merely well-formed `PermissionId` string. Two properties matter:
|
|
|
|
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
|
|
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
|
|
is declared twice — this can only happen while wiring the registry itself, not at request time.
|
|
- **Unknown permissions fail closed at lookup time.** `registry.get(permissionId)` and
|
|
`registry.has(permissionId)` both re-validate their input (`safeParsePermissionId` internally)
|
|
and return `undefined`/`false` for anything malformed or simply not registered — there is no
|
|
"trust this permission id because it looks right" path.
|
|
|
|
Permission matching is always exact string equality, both in the registry and in the evaluator's
|
|
grant lookup — there is no wildcard, prefix, or namespace-inheritance matching anywhere; the
|
|
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
|
|
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
|
|
|
|
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and
|
|
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
|
|
same declarations exported separately in deeply immutable form, for the module composition helper
|
|
below to build on without reconstructing Core's own list:
|
|
|
|
| Permission | Baseline | Description |
|
|
| --- | --- | --- |
|
|
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
|
|
|
## Module permission composition
|
|
|
|
A module manifest's `permissions` array (`manifestVersion: 3`,
|
|
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
|
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
|
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
|
anything. `composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) is the trusted
|
|
composer: given an already-discovered module list (for example `discoverModules`'s result), it
|
|
builds one combined `PermissionRegistry` from `CORE_PERMISSION_DECLARATIONS` followed by every
|
|
module's declarations, in discovery order, using the same `createPermissionRegistry` constructor —
|
|
not a second registry implementation. Declaration order is deterministic: Core first, then each
|
|
module in the order it appears in the supplied list, then each module's own declarations in
|
|
manifest-array order. A duplicate exact permission id anywhere in that combined list fails
|
|
composition the same way a duplicate would fail `createPermissionRegistry` directly — since a
|
|
permission id must start with its own module's id, this is only reachable across two entries that
|
|
share the same module id, which real discovery already rules out (`MODULE_ID_DUPLICATE`);
|
|
composition still re-derives the check rather than trusting that guarantee alone.
|
|
|
|
`core.*` is reserved for Core's own permissions. A discovered module contributing any
|
|
`core.`-prefixed permission id fails composition with `ModulePermissionCompositionError`
|
|
(`MODULE_PERMISSION_RESERVED_NAMESPACE`) before duplicate detection ever runs — since every
|
|
Core-owned id is itself `core.`-prefixed, this is what actually catches a module attempting to
|
|
collide with a Core-owned id specifically, regardless of that module's own id and regardless of
|
|
whether the id happens to exactly match an existing declaration.
|
|
|
|
Composition itself performs no filesystem, environment, database, network, or module-code
|
|
operation — it is pure, synchronous, in-memory composition over data a caller already discovered
|
|
and validated (`core/src/authorization` and `core/src/modules` remain independent of each other in
|
|
the other direction: authorization never imports module discovery or runtime, only the module
|
|
integration layer depends on authorization). This pass stops at producing the combined registry;
|
|
wiring it into `apps/api` in place of `CORE_PERMISSION_REGISTRY`, and any module-owned HTTP
|
|
enforcement built on it, is later work.
|
|
|
|
### Two layers, not one: shape versus registration
|
|
|
|
`createAuthorizationEvaluator` (`core/src/authorization/evaluator.ts`) has no registry and is
|
|
never given one — it independently re-validates `permission`, `baseline`, and `scope` as a pure
|
|
shape check (`safeParsePermissionId`/`permissionBaselineSchema`/`safeParseAuthorizationScope`) and
|
|
denies a malformed value under **every** baseline, including `public` and `authenticated`, before
|
|
any baseline is allowed to return an allowed decision. This is defense in depth for a future
|
|
caller that reaches the evaluator directly, not a substitute for the registry: the evaluator has
|
|
no way to tell a well-formed-but-unregistered permission id (for example a plausible-looking
|
|
`calendar.events.read` that nothing has ever declared) from a genuinely registered one — it can
|
|
only reject what is not even shaped like a `PermissionId` at all.
|
|
|
|
*Registration* — "does this permission actually exist" — is exclusively the registry's
|
|
responsibility, consulted by every trusted caller before the evaluator ever runs:
|
|
`requirePermission` (`apps/api/src/authorization-middleware.ts`) looks up the *caller's own*
|
|
required permission (for example `core.authorization.grants.manage`, always a hardcoded route
|
|
constant, never client-supplied) in the registry before resolving a principal or evaluating
|
|
anything; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately looks up the *target*
|
|
permission being granted — the one named in the request body — in the same registry, after body
|
|
shape-validation and before ever calling `store.createGrant`, so a caller cannot create a dormant
|
|
grant for an invented permission id that might later become effective the moment someone actually
|
|
registers it. A malformed target permission (fails `PermissionId` shape validation) still resolves
|
|
to the body-level `400 {"code":"INVALID_REQUEST"}` documented below, unchanged; a well-formed but
|
|
unregistered target permission instead receives the identical sanitized
|
|
`403 {"code":"AUTHORIZATION_DENIED"}` a genuinely denied caller would, so that response never
|
|
reveals whether the caller was denied or the permission was simply never registered.
|
|
|
|
## Platform-owner bootstrap
|
|
|
|
There is no "first user becomes owner" inference anywhere in this repository, and no HTTP
|
|
endpoint exposes bootstrap. The only way to establish a platform owner is the local CLI command:
|
|
|
|
```bash
|
|
pnpm migrations:apply # the database must already be migrated
|
|
DATABASE_URL=postgresql://... pnpm authorization:bootstrap-owner -- \
|
|
--user-id <existing-ekklesia-user-id> \
|
|
--confirm-user-id <same-id>
|
|
```
|
|
|
|
`DATABASE_URL` is read only when the command actually executes (never merely by importing the CLI
|
|
module — proved by `core/tests/cli-bootstrap-platform-owner.test.ts`), and it is never printed,
|
|
logged, or included in any error. `--user-id` and `--confirm-user-id` must match exactly, checked
|
|
before the id is even parsed as a valid `EkklesiaUserId` — this command never infers a target from
|
|
signup order or "the only user in the database"; the operator must name the exact, opaque id
|
|
twice.
|
|
|
|
### Bootstrap is permanently single-use
|
|
|
|
`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated,
|
|
Hono-independent Core operation — deliberately *not* a composition of "check for an owner, then
|
|
call the existing grant function." "Already consumed" means **any row has ever existed** in
|
|
`ekklesia_core_platform_owner`, for any user, including one whose `revoked_at` is set. There is no
|
|
owner-management flow in this slice that clears that state; a revoked owner staying revoked (and
|
|
bootstrap staying consumed) is intentional here.
|
|
|
|
The single-use check, the target user's existence check, the owner-grant insert, and its audit
|
|
entry are all one PostgreSQL transaction. The very first statement inside that transaction is a
|
|
transaction-scoped advisory lock:
|
|
|
|
```sql
|
|
select pg_advisory_xact_lock(1500000001, 1500000002)
|
|
```
|
|
|
|
This lock key pair is distinct from the migration runner's own advisory lock
|
|
(`core/src/migrations/postgres-database.ts`), so bootstrap serialization can never collide with or
|
|
be blocked by unrelated migration locking. `pg_advisory_xact_lock` releases automatically on
|
|
commit or rollback, so no manual unlock path is needed even when the transaction fails partway.
|
|
Two concurrent bootstrap attempts serialize on this lock: the second caller blocks until the
|
|
first's transaction commits or rolls back, then re-evaluates the "does any owner row exist" check
|
|
against now-current state — so exactly one of two concurrent attempts ever succeeds, proved live
|
|
against real PostgreSQL in `core/tests/authorization-bootstrap.integration.ts`
|
|
(`Promise.allSettled` on two simultaneous calls; asserts exactly one `fulfilled`, exactly one
|
|
`rejected` with `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED`, exactly one owner row, exactly one
|
|
audit row).
|
|
|
|
A failed target-user lookup, an audit-insert failure, or losing the concurrency race all roll back
|
|
the entire transaction — there is no partial state where an owner row exists without its audit
|
|
entry, or vice versa.
|
|
|
|
The audit entry for a bootstrap grant always uses a fixed, sanitized, non-user actor identity —
|
|
never derived from any caller input — so an auditor can tell a given `platform_owner.grant` row
|
|
came specifically from this command rather than from a future owner-management flow:
|
|
|
|
| Field | Value |
|
|
| --- | --- |
|
|
| `actor_type` | `system` |
|
|
| `actor_id` | `authorization-bootstrap-cli` |
|
|
|
|
### Error codes
|
|
|
|
`PlatformOwnerBootstrapError` never carries the underlying database error, SQL text, host, port,
|
|
path, or stack — only a stable `code` and a fixed message:
|
|
|
|
| Code | Meaning |
|
|
| --- | --- |
|
|
| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | A platform-owner row already exists (any user, revoked or not). |
|
|
| `PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. |
|
|
| `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). |
|
|
|
|
## HTTP authorization enforcement
|
|
|
|
`apps/api/src/authorization-middleware.ts` exports `requirePermission(options, permissionId)`, a
|
|
Hono middleware factory reused by every protected route:
|
|
|
|
1. Looks up `permissionId` in the trusted, server-side `PermissionRegistry` (never a
|
|
client-supplied baseline) — an unregistered permission id fails closed with `403` before the
|
|
principal is even resolved. In today's wiring only hard-coded registered constants are ever
|
|
passed by route code, but the check stays live per-request as defense in depth rather than
|
|
being trusted once at composition time.
|
|
2. Resolves the principal exclusively through the same server-side identity runtime
|
|
`/api/v1/principal` already uses (`EkklesiaIdentityRuntime.resolvePrincipal`) — nothing here
|
|
ever trusts a client-supplied claim about identity.
|
|
3. Calls the injected `AuthorizationEvaluator` with the registry's baseline and permission for
|
|
that principal (platform owners pass through the evaluator's owner bypass here — this is how
|
|
delegation to `core.authorization.grants.manage` recipients also works, without a second code
|
|
path).
|
|
4. On success, stores the resolved `EkklesiaPrincipal` (Core's narrow `{kind, userId}` — never a
|
|
Better Auth session/user object) on the Hono context via `c.set`, readable by the route through
|
|
`getAuthorizedPrincipal(c)`. This is a deliberately narrow exception to the "no generalized
|
|
session/user object in Hono context" concern [ADR
|
|
0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) raised —
|
|
only this already-public-safe principal shape is ever set.
|
|
|
|
A denied decision and an unregistered permission id both return a sanitized `403
|
|
{"code":"AUTHORIZATION_DENIED"}`. A genuine infrastructure failure — principal resolution,
|
|
evaluation, or the database underneath either — is never folded into that `403`; it returns a
|
|
sanitized `503` instead (`PRINCIPAL_RESOLUTION_FAILED` or `AUTHORIZATION_EVALUATION_FAILED`),
|
|
mirroring `/api/v1/principal`'s existing rule that "could not be determined" must never look like
|
|
"no permission." Neither response ever reveals whether a grant or owner row exists for the caller.
|
|
|
|
### Origin enforcement for mutations
|
|
|
|
`requireTrustedOrigin(trustedOrigins)` requires an exact match against the same
|
|
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` set the API already uses for CORS and Better Auth's own
|
|
checks (see [API](api.md#origin-cors-and-cookies)) — a missing or untrusted `Origin` header is
|
|
rejected with `403 {"code":"UNTRUSTED_ORIGIN"}` before the permission check or the route handler
|
|
ever runs. This exists because CORS response headers alone are not CSRF protection: they only
|
|
stop a browser from *reading* a cross-origin response, not from *sending* the state-changing
|
|
request in the first place. Every grants mutation route (`POST /api/v1/grants`,
|
|
`POST /api/v1/grants/:grantId/revoke`) requires this; the read-only list route does not.
|
|
Non-browser, non-cookie automation (service accounts/API tokens) is out of scope for this slice
|
|
and has no way to satisfy this check yet.
|
|
|
|
## Explicit-grant HTTP API
|
|
|
|
Mounted at the API's root by `apps/api/src/grants-routes.ts`. Every route requires
|
|
`core.authorization.grants.manage`. All three routes only ever act on *explicit* grants — the
|
|
generic HTTP API structurally cannot create or revoke a relationship-derived grant.
|
|
|
|
| Route | Origin required | Notes |
|
|
| --- | --- | --- |
|
|
| `GET /api/v1/grants?userId=<id>` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). |
|
|
| `POST /api/v1/grants` | Yes | Creates a new explicit grant. |
|
|
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. |
|
|
|
|
### `POST /api/v1/grants` request body
|
|
|
|
Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`,
|
|
`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or
|
|
anything else) makes the **entire body** invalid rather than being silently dropped, so a caller
|
|
cannot smuggle a server-derived field through a request that otherwise looks well-formed:
|
|
|
|
| Field | Required | Notes |
|
|
| --- | --- | --- |
|
|
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
|
|
| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. |
|
|
| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. |
|
|
| `expiresAt` | No | An ISO date string if present. |
|
|
|
|
The server always derives, and the request body can never supply: the grant id, `sourceKind`
|
|
(always `"explicit"`), the actor (always the authenticated caller, via
|
|
`createUserActor(principal.userId)`), and all audit/timestamp fields.
|
|
|
|
### Response shapes
|
|
|
|
A grant, wherever returned:
|
|
|
|
```json
|
|
{
|
|
"id": "...",
|
|
"userId": "...",
|
|
"permission": "core.authorization.grants.manage",
|
|
"scope": null,
|
|
"sourceKind": "explicit",
|
|
"createdAt": "2026-07-23T00:00:00.000Z",
|
|
"expiresAt": null,
|
|
"revokedAt": null
|
|
}
|
|
```
|
|
|
|
`GET /api/v1/grants` → `200 {"grants": [...]}`. `POST /api/v1/grants` → `201 {"grant": {...}}`.
|
|
`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`.
|
|
|
|
### Status-code mapping
|
|
|
|
| Status | When |
|
|
| --- | --- |
|
|
| `200` / `201` | Success. |
|
|
| `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). |
|
|
| `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`; the caller's own required permission id was unregistered; or, for `POST /api/v1/grants`, the well-formed *target* permission named in the body is not registered. |
|
|
| `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. |
|
|
| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. |
|
|
| `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). |
|
|
|
|
Every response sets `Cache-Control: no-store`.
|
|
|
|
## Shared database composition
|
|
|
|
`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api`
|
|
touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded
|
|
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two
|
|
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
|
|
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
|
|
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
|
|
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
|
|
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
|
|
`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown,
|
|
and on any startup failure after the pool was already constructed (mirroring the existing
|
|
identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
|
|
|
|
The existing per-domain factories (`createIdentityDrizzleDatabase`,
|
|
`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are
|
|
unaffected and remain available for standalone/testable use — the CLI bootstrap command uses
|
|
`createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs
|
|
the authorization schema and has no reason to share a pool with anything else.
|
|
|
|
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
|
|
`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization
|
|
usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers
|
|
there are or whether the failure happened during startup.
|
|
|
|
## Tests
|
|
|
|
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
|
|
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY`/`CORE_PERMISSION_DECLARATIONS` contents
|
|
and immutability.
|
|
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
|
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
|
rejection, malformed permission id rejection.
|
|
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 3` acceptance and version
|
|
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
|
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
|
path, and default-empty-list isolation across separate parses.
|
|
- `core/tests/module-permissions.test.ts` — `composePlatformPermissionRegistry`: Core-only and
|
|
Core-plus-modules composition, deterministic ordering, exact baseline/description preservation,
|
|
duplicate rejection, reserved `core.*` namespace rejection, unknown-permission denial once a
|
|
module is absent from the supplied list, and input/output immutability with no I/O.
|
|
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
|
|
per-domain database tests).
|
|
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`
|
|
behavior.
|
|
- `core/tests/cli-bootstrap-platform-owner.test.ts` — the CLI never constructs a pool merely by
|
|
being imported.
|
|
- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and
|
|
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
|
|
still blocking reuse, and the concurrency scenario described above.
|
|
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
|
|
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior.
|
|
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
|
|
and malformed-field rejection, a well-formed but unregistered target permission rejected with
|
|
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
|
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
|
store-error mapping.
|
|
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above.
|
|
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
|
|
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
|
consulting the grant or platform-owner ports for `public`/`authenticated`.
|
|
- `apps/api/tests/api-http.integration.ts` (live PostgreSQL) — end-to-end: anonymous/non-owner
|
|
denial, real bootstrap against the running API's own database, owner authority over the HTTP
|
|
grants API, the owner delegating `core.authorization.grants.manage` to another account and that
|
|
account then managing grants as a non-owner, a well-formed but unregistered target permission
|
|
rejected with `403` (with no grant or audit row written) even for a caller with full
|
|
grant-management authority, a create/list/revoke cycle with audit-trail verification (using
|
|
`core.authorization.grants.manage` itself as the granted permission, since module permission
|
|
registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection,
|
|
a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an
|
|
infrastructure-failure scenario (database dropped out from under a live session) asserting a
|
|
`503` rather than a false `403` or `200`.
|
|
|
|
Run the focused suites:
|
|
|
|
```bash
|
|
pnpm --filter @ekklesia/core test
|
|
pnpm --filter @ekklesia/api test
|
|
pnpm test:migrations:integration # requires Docker
|
|
```
|
|
|
|
## Next phase
|
|
|
|
Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission
|
|
registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service
|
|
accounts/API tokens for non-browser automation, and relationship-derived grant creation itself
|
|
(a roster or similar module operation, not the generic grants API).
|