feat: integrate module authorization

Wires composePlatformPermissionRegistry into apps/api's production
startup: startApi() discovers every installed module, composes the one
immutable platform permission registry (Core's own declarations plus
every module's), and only then constructs the shared PostgreSQL pool,
Better Auth, and the HTTP server. A discovery or composition failure —
an invalid manifest, a reserved core.* namespace claim, or a duplicate
declaration — propagates before anything exists to close; there is no
Core-only registry fallback. apps/api/src/platform-permissions.ts
resolves the repository root from its own file location, never
process.cwd(), so package-scoped test runs discover the right modules/
directory.

createApiApp/createGrantsRoutes now require the composed registry as
an injected authorization option, replacing the previous hard-coded
CORE_PERMISSION_REGISTRY; requirePermission continues to work
identically for Core-owned and module-owned permissions, since it has
no concept of the distinction.

Extends pnpm doctor with a modules.permissions check (reusing the
already-discovered module list, distinguishing reserved-namespace from
generic-duplicate failures, never re-discovering modules) and pnpm
modules:validate to compose the same registry before a module is
considered installable.

Adds focused coverage: startup-ordering and failure-isolation tests in
apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement
tests in apps/api/tests/authorization-middleware.test.ts, Doctor
composition tests in core/tests/doctor.test.ts, and a live PostgreSQL
end-to-end scenario proving a real installed module's permission is
discoverable, grantable, and deniable once the module is absent again
after a restart, without ever executing its server entrypoint.
This commit is contained in:
ekklesia
2026-07-24 14:24:50 -05:00
parent d9dd409aa3
commit edd7148aa8
17 changed files with 1205 additions and 46 deletions
+12
View File
@@ -206,6 +206,18 @@ trusted-proxy IP policy is configured, Better Auth may fall back to a single sha
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
abuse-control design.
## Module permission discovery at startup
Before any database pool, Better Auth instance, or HTTP server exists, `startApi()` discovers
every installed module's manifest and composes the one immutable platform permission registry
(Core's own declarations plus every module's) that the rest of startup — and every subsequent
request — enforces against. A discovery or composition failure (an invalid manifest, a module
claiming the reserved `core.*` permission namespace, a duplicate permission declaration, ...) fails
startup outright, before there is anything to close; startup never falls back to a Core-only
registry. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement)
for the full flow, and [Module development](module-development/README.md) for the manifest shape
that produces these declarations.
## Database connection limits
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
+104 -15
View File
@@ -96,9 +96,73 @@ Composition itself performs no filesystem, environment, database, network, or mo
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.
integration layer depends on authorization).
## API startup: discovery, composition, and enforcement
`apps/api` builds its production permission registry from the complete 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 (`discoverModules`).
3. Compose the platform permission registry (`composePlatformPermissionRegistry`).
4. Construct the shared PostgreSQL pool.
5. Construct Identity and Authorization.
6. Create the Hono application, injecting the composed registry.
7. Bind the HTTP server.
`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3.
Discovery and composition happen strictly before any pool, Better Auth instance, 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); 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 anyone
can newly grant, revoke, or have authorized against.
Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived
grant creation both remain deferred, unrelated slices — this work only makes a module's *declared*
permission usable through Core's existing, generic explicit-grant HTTP API and enforcement
middleware.
## 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
@@ -265,7 +329,7 @@ cannot smuggle a server-derived field through a request that otherwise looks wel
| 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`. |
| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in the running API's composed platform registry (Core's own declarations plus every installed module's — see [API startup](#api-startup-discovery-composition-and-enforcement)). A malformed value is a `400`; a well-formed but unregistered one is a `403`. |
| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. |
| `expiresAt` | No | An ISO date string if present. |
@@ -357,13 +421,27 @@ there are or whether the failure happened during startup.
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.
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior;
a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/
`restricted` declarations identically to Core's own, that the evaluator receives the registry's
exact id/baseline, and that an absent module permission denies before principal resolution or
evaluator access — the shared mechanism, not a module-specific code path.
- `core/tests/doctor.test.ts` / `core/tests/doctor-report.test.ts` — the `modules.permissions`
check: passing count (including zero installed modules), reuse of the already-discovered module
list (no rediscovery), distinct reserved-namespace vs. generic-duplicate failure detail, an
unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing
independently after a composition failure, and the exact documented terminal report shape.
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
and malformed-field rejection, a well-formed but unregistered target permission rejected with
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
store-error mapping.
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above.
- `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`.
- `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`.
@@ -373,11 +451,21 @@ there are or whether the failure happened during startup.
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`.
`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 server entrypoint
that throws if ever imported) 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; API startup discovers the fixture 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; a well-formed
permission absent from the composed registry is rejected with no grant or audit write; and,
after a clean shutdown and restart with the module absent, the same permission can no longer be
newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity
HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout.
Run the focused suites:
@@ -389,7 +477,8 @@ 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).
Phase 5A, pass 3: deferred from this slice — module HTTP route-registration contracts (a module
mounting its own endpoints), a module runtime-context authorization service, relationship-derived
grant creation itself (a roster or similar module operation, not the generic grants API),
roles/groups/teams/tenants, an owner-management HTTP endpoint, and service accounts/API tokens for
non-browser automation.
+2
View File
@@ -101,6 +101,8 @@ Modules
0 modules installed
✓ Dependency graph valid
Dependency order is empty.
✓ Permission registry composed
1 permission registered
Data definitions
✓ Schemas validated