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:
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user