|
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
# 0014: Module permission declarations and platform permission registry
|
|
|
|
|
|
|
|
|
|
- Status: Accepted
|
|
|
|
|
- Date: 2026-07-24
|
|
|
|
|
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md)
|
|
|
|
|
- Supersedes (partial): [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) — only
|
|
|
|
|
its `manifestVersion: 2` and bare-permission-string decision. Capabilities, the compiled-runtime
|
|
|
|
|
production loading boundary, and registration/lifecycle ordering established in ADR 0006 remain
|
|
|
|
|
accepted and unchanged.
|
|
|
|
|
|
|
|
|
|
## Context
|
|
|
|
|
|
|
|
|
|
ADR 0013 established Core's authorization foundation — schema, evaluator, store, permission
|
|
|
|
|
registry, platform-owner bootstrap, and HTTP enforcement — but explicitly deferred module
|
|
|
|
|
permission registration: a module manifest (ADR 0006, `manifestVersion: 2`) could declare a
|
|
|
|
|
`permissions` array, but only as bare namespaced strings. That was enough to validate a namespace
|
|
|
|
|
at manifest-parse time, but not enough to *register* anything: Core's authorization registry needs
|
|
|
|
|
a trusted baseline and an operator-facing description for every permission it enforces, and a bare
|
|
|
|
|
string carries neither. Phase 5A closes that gap in two passes on this branch: pass 1
|
|
|
|
|
(`feat: declare module permissions`) established the shared declaration contract, manifest version
|
|
|
|
|
3, and a pure composition function; pass 2 (`feat: integrate module authorization`) wired the
|
|
|
|
|
composed registry into `apps/api`'s production startup and its existing enforcement/explicit-grant
|
|
|
|
|
HTTP surface. This ADR records the durable architecture both passes established.
|
|
|
|
|
|
|
|
|
|
## Decision
|
|
|
|
|
|
|
|
|
|
### `PermissionDeclaration` is the one shared, contracts-layer definition
|
|
|
|
|
|
|
|
|
|
`packages/contracts/src/authorization.ts`'s `permissionDeclarationSchema` is the single
|
|
|
|
|
authoritative shape of a registered permission declaration — `id` (a branded `PermissionId`),
|
|
|
|
|
`baseline` (`public`/`authenticated`/`restricted`, the evaluator's trusted baseline), and
|
|
|
|
|
`description` (required, trimmed, non-empty, capped at 240 characters). `.strict()` rejects any
|
|
|
|
|
other property. Both Core's own authorization registry
|
|
|
|
|
(`core/src/authorization/permissions.ts`) and a module manifest's `permissions` array
|
|
|
|
|
(`packages/contracts/src/module-manifest.ts`) consume this exact type — Core does not declare a
|
|
|
|
|
second, independently maintained interface, and a module cannot smuggle extra fields into a
|
|
|
|
|
declaration Core treats as trusted. `description` is informational only: audit and operator UI
|
|
|
|
|
text, never read or interpreted by the evaluator, the registry, or enforcement — changing it can
|
|
|
|
|
never change what a permission allows.
|
|
|
|
|
|
|
|
|
|
### Module manifests are inert JSON and the sole permission-declaration source
|
|
|
|
|
|
|
|
|
|
A module's `ekklesia.module.json` remains inert, static data Core parses without executing any
|
|
|
|
|
module code — consistent with ADR 0006's discovery model. Its `permissions` array is the only
|
|
|
|
|
place a module declares permissions; nothing in `defineModuleRuntime` (Module SDK,
|
|
|
|
|
`packages/module-sdk/src/define-module-runtime.ts`) repeats, duplicates, or otherwise re-declares
|
|
|
|
|
permissions, baselines, or descriptions — exactly like it already does not repeat capabilities,
|
|
|
|
|
dependencies, storage requests, or entrypoints (ADR 0006). Core never imports or runs a module's
|
|
|
|
|
server entrypoint, job, or lifecycle hook to discover its permissions; only the manifest is read.
|
|
|
|
|
|
|
|
|
|
### Manifest version 3 replaces version 2's bare permission strings
|
|
|
|
|
|
|
|
|
|
Representing a permission as a complete declaration instead of a bare identifier string is an
|
|
|
|
|
incompatible manifest shape change, so the canonical manifest moves to `manifestVersion: 3`
|
|
|
|
|
(`z.literal(3)`). Version 2 — and any other version — fails validation outright; there is no
|
|
|
|
|
compatibility adapter, no automatic conversion from a string array to declarations, and no dual
|
|
|
|
|
acceptance of both shapes. A module author must rewrite `permissions` explicitly. `permissions`
|
|
|
|
|
still defaults to an empty array when the field is omitted entirely, exactly as version 2 did.
|
|
|
|
|
|
|
|
|
|
### Each module owns exactly its own namespace; `core.*` is reserved
|
|
|
|
|
|
|
|
|
|
A permission id declared in a module's manifest must begin with `<module-id>.`, checked the same
|
|
|
|
|
way a capability id already is (ADR 0006). Module ids never contain a dot (`moduleIdSchema` is
|
|
|
|
|
plain kebab-case), so the only module whose manifest could ever declare a `core.`-prefixed
|
|
|
|
|
permission and still pass this per-manifest namespace check is one whose own id is literally
|
|
|
|
|
`"core"` — `core.*` itself remains reserved for Core's own permissions, enforced independently at
|
|
|
|
|
composition time (below). Duplicate permission ids within one manifest fail manifest validation at
|
|
|
|
|
the exact issue path `permissions[index].id`, mirroring the existing capability and storage-bucket
|
|
|
|
|
duplicate checks.
|
|
|
|
|
|
|
|
|
|
### Core and installed-module declarations compose into one immutable registry
|
|
|
|
|
|
|
|
|
|
`composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) builds the platform's one
|
|
|
|
|
permission registry from Core's own declarations (`CORE_PERMISSION_DECLARATIONS`) plus every
|
|
|
|
|
already-discovered module's declarations. It reuses `createPermissionRegistry`
|
|
|
|
|
(`core/src/authorization/permissions.ts`) rather than reimplementing registry construction or
|
|
|
|
|
duplicate detection — there is no second, module-specific registry type. Order is deterministic:
|
|
|
|
|
Core's declarations first, then each module in the exact order it appears in the caller-supplied
|
|
|
|
|
list, then each module's own declarations in manifest-array order. The resulting registry, and
|
|
|
|
|
every declaration it exposes, is immutable (`Object.freeze`), matching `createPermissionRegistry`'s
|
|
|
|
|
existing guarantee. Composition is pure, synchronous, in-memory work over data the caller already
|
|
|
|
|
discovered: no filesystem, environment, database, network, or module-code operation happens inside
|
|
|
|
|
it.
|
|
|
|
|
|
|
|
|
|
`core/src/authorization` continues to know nothing about modules — it does not import module
|
|
|
|
|
discovery or runtime. `core/src/modules/permissions.ts` is the one-directional module-integration
|
|
|
|
|
layer that depends on `core/src/authorization`, never the reverse.
|
|
|
|
|
|
|
|
|
|
### The structural collision guarantee
|
|
|
|
|
|
|
|
|
|
A discovered module contributing any `core.`-prefixed permission id fails composition with
|
|
|
|
|
`ModulePermissionCompositionError` (`MODULE_PERMISSION_RESERVED_NAMESPACE`) — checked, and thrown,
|
|
|
|
|
before that declaration is ever added to the list `createPermissionRegistry` sees. This is a
|
|
|
|
|
deliberate architectural guarantee, not an incidental test limitation, and it follows from three
|
|
|
|
|
facts holding simultaneously: real module discovery rejects a duplicate module id
|
|
|
|
|
(`MODULE_ID_DUPLICATE`), so module ids are unique; a manifest's own namespace check requires every
|
|
|
|
|
permission id to begin with `<module-id>.`; and a manifest already rejects a duplicate permission
|
|
|
|
|
id within itself. Together, these mean a module can only ever produce a `core.`-prefixed permission
|
|
|
|
|
if its own id is literally `"core"` — and when it does, that declaration is caught by the
|
|
|
|
|
reserved-namespace check specifically, never by `createPermissionRegistry`'s generic duplicate
|
|
|
|
|
detection (`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`). The same three
|
|
|
|
|
facts also mean a genuine *module-vs-module* exact permission-id collision cannot arise from a
|
|
|
|
|
valid, really-discovered installed-module set at all — two different module ids can never produce
|
|
|
|
|
the identical permission string. `createPermissionRegistry`'s own duplicate rejection remains in
|
|
|
|
|
place regardless, as an unconditional, defense-in-depth guarantee of the underlying registry
|
|
|
|
|
primitive; both Core's test suite and this composer's own tests exercise it directly with
|
|
|
|
|
deliberately conflicting internal test data (not reachable through real discovery), specifically to
|
|
|
|
|
prove the guarantee holds even though production traffic cannot currently trigger it. Both error
|
|
|
|
|
families carry only a module id and/or permission id — never a module directory, manifest path,
|
|
|
|
|
caught cause, or stack.
|
|
|
|
|
|
|
|
|
|
### Module discovery and permission composition precede all database and HTTP construction
|
|
|
|
|
|
|
|
|
|
`apps/api`'s `startApi()` (`apps/api/src/server.ts`) composes the platform registry
|
|
|
|
|
(`composeStartupPermissionRegistry`, `apps/api/src/platform-permissions.ts`) as its first step,
|
|
|
|
|
strictly before constructing the shared PostgreSQL pool, Better Auth, the authorization store and
|
|
|
|
|
evaluator, the Hono application, or binding the HTTP server. A discovery or composition failure —
|
|
|
|
|
an invalid manifest, an incompatible dependency graph, a reserved-namespace claim, or a duplicate
|
|
|
|
|
declaration — therefore has nothing to close: it propagates directly out of `startApi()`, and
|
|
|
|
|
startup never falls back to a Core-only registry. Modules are discovered exactly once for this
|
|
|
|
|
composition; nothing re-discovers them a second time for permissions specifically.
|
|
|
|
|
|
|
|
|
|
Repository and package-version information is derived from the installed repository's own layout —
|
|
|
|
|
never `process.cwd()`, which a package-scoped test run does not set to the repository root. The
|
|
|
|
|
repository root is resolved from the composing module's own compiled/source file location (a fixed
|
|
|
|
|
number of directory levels up, identical whether running `apps/api/dist/*.js` or, under a test
|
|
|
|
|
runner, `apps/api/src/*.ts`); the platform and Module SDK versions are read from their real
|
|
|
|
|
`package.json` files, only when startup actually executes — never merely by importing the API's
|
|
|
|
|
server, config, or app modules, which remain side-effect-free at import time.
|
|
|
|
|
|
|
|
|
|
### API enforcement and explicit grants use one registry for Core and module permissions alike
|
|
|
|
|
|
|
|
|
|
`createApiApp` requires the composed platform registry as an injected authorization option;
|
|
|
|
|
`startApi` is the only production caller, and it always supplies the registry
|
|
|
|
|
`composeStartupPermissionRegistry` produced. There is no `CORE_PERMISSION_REGISTRY` fallback
|
|
|
|
|
anywhere in `apps/api` — not in `createApiApp`, not in `createGrantsRoutes`, not in the
|
|
|
|
|
`requirePermission` middleware. `requirePermission` has no notion of "Core" versus "module"
|
|
|
|
|
permissions; it resolves a route's declared `PermissionId` and its trusted baseline from whichever
|
|
|
|
|
registry it is given, denies before principal resolution or evaluator access when the permission is
|
|
|
|
|
unregistered, and never accepts a baseline, actor, or any other authorization-relevant field from
|
|
|
|
|
client input. A module's `public`, `authenticated`, or `restricted` declaration is therefore
|
|
|
|
|
enforced identically to Core's own `core.authorization.grants.manage`, through the identical
|
|
|
|
|
evaluator and grant path, and — because registration and evaluator shape validation remain the two
|
|
|
|
|
separate, independent fail-closed layers ADR 0013 established — a well-formed but unregistered
|
|
|
|
|
permission (module or Core) still receives a sanitized `403` with no store or audit write, whether
|
|
|
|
|
the request is a permission check or an explicit-grant creation attempt on `POST /api/v1/grants`.
|
|
|
|
|
|
|
|
|
|
### The registry is a static startup snapshot
|
|
|
|
|
|
|
|
|
|
There is no hot installation, removal, or dynamic mutation of the composed registry while `apps/api`
|
|
|
|
|
is running. Installing, removing, or editing a module's manifest takes effect only on the next API
|
|
|
|
|
restart. Removing a module from the next startup's discovered inventory makes its permission
|
|
|
|
|
unknown to new authorization checks and new explicit grants — it denies exactly like any other
|
|
|
|
|
unregistered permission — but never deletes, revokes, or otherwise modifies a historical grant or
|
|
|
|
|
audit row already recorded for that permission. A previously granted, still-unrevoked explicit
|
|
|
|
|
grant remains listed through `GET /api/v1/grants`, and revoking it explicitly remains possible,
|
|
|
|
|
since revocation is keyed to the grant's own id and source kind, not to whether its permission is
|
|
|
|
|
currently registered. What happens if the exact same permission id is later reinstalled or
|
|
|
|
|
reactivated in a future startup — whether a preserved, still-active grant becomes effective again
|
|
|
|
|
automatically — is **explicitly not decided by this ADR** and remains deferred; nothing here should
|
|
|
|
|
be read as a guarantee either way.
|
|
|
|
|
|
|
|
|
|
### Doctor and `modules:validate` compose the same inventory
|
|
|
|
|
|
|
|
|
|
`pnpm doctor`'s `modules.permissions` check and `pnpm modules:validate` both call
|
|
|
|
|
`composePlatformPermissionRegistry` over the module list their own already-completed discovery
|
|
|
|
|
produced — neither rediscovers modules solely to check permissions. A structurally valid manifest
|
|
|
|
|
that claims the `core.*` namespace, or that would produce a duplicate declaration, now fails before
|
|
|
|
|
that module is considered installable (`modules:validate`) or reported healthy (`doctor`).
|
|
|
|
|
|
|
|
|
|
### Test-only discovery overrides are not a production configuration surface
|
|
|
|
|
|
|
|
|
|
`composeStartupPermissionRegistry`'s `modulesDirectoryOverride` and `discoveredModulesOverride`
|
|
|
|
|
exist solely so tests can exercise discovery and composition against controlled fixtures or
|
|
|
|
|
deliberately conflicting data — the latter specifically because a genuine module-vs-module
|
|
|
|
|
duplicate is otherwise unreachable through real discovery, as established above. Neither is read
|
|
|
|
|
from an environment variable, an HTTP header, or any other client- or deployment-controlled input;
|
|
|
|
|
`main()`, the executable entry point, never sets either.
|
|
|
|
|
|
|
|
|
|
## Alternatives
|
|
|
|
|
|
|
|
|
|
- **Runtime, code-based permission registration** (a module registering permissions imperatively
|
|
|
|
|
from its server entrypoint or a registration hook) was rejected: it would require executing
|
|
|
|
|
module code to discover what a module can even offer, breaking the inert-manifest discovery
|
|
|
|
|
model ADR 0006 already established and that Doctor and `modules:validate` depend on for
|
|
|
|
|
offline, side-effect-free validation.
|
|
|
|
|
- **A second, module-specific registry implementation** was rejected in favor of reusing
|
|
|
|
|
`createPermissionRegistry` — a second implementation would need to reimplement duplicate
|
|
|
|
|
detection, immutability, and fail-closed lookup independently, with no guarantee the two stayed
|
|
|
|
|
behaviorally identical.
|
|
|
|
|
- **Falling back to `CORE_PERMISSION_REGISTRY` when discovery or composition fails** was rejected:
|
|
|
|
|
it would let the API start in a state where module-declared permissions silently do not exist,
|
|
|
|
|
which is indistinguishable from "no modules were ever installed" and would mask a real
|
|
|
|
|
configuration error as ordinary, healthy startup.
|
|
|
|
|
- **Dynamic, mutable, or hot-reloaded registration** (updating the registry while the process
|
|
|
|
|
runs, without a restart) was rejected for this phase: it introduces concurrent-mutation and
|
|
|
|
|
partial-update hazards this phase does not need to solve, and a static snapshot is sufficient
|
|
|
|
|
for the current deployment model.
|
|
|
|
|
- **Silently accepting version-2 manifests** (auto-converting a bare permission string into a
|
|
|
|
|
declaration with a synthesized baseline/description) was rejected: any synthesized baseline
|
|
|
|
|
would be a guess Core cannot trust, and the whole point of requiring a real declaration is that
|
|
|
|
|
the baseline and description come from the module author, not from Core inventing them.
|
|
|
|
|
- **Deleting or automatically revoking a module's grants when the module disappears** was
|
|
|
|
|
rejected: it would destroy an audit trail that must remain queryable regardless of a module's
|
|
|
|
|
current installation state, and revocation is a deliberate, actor-attributed action elsewhere in
|
|
|
|
|
this system (ADR 0013) — an uninstall is not that action.
|
|
|
|
|
- **Reading a permission's baseline from HTTP request input** (letting a caller assert which
|
|
|
|
|
baseline to check) was already rejected by ADR 0013 and remains rejected here: the baseline is
|
|
|
|
|
never anything but the registry's own trusted value for a request-supplied `PermissionId`.
|
|
|
|
|
|
|
|
|
|
## Consequences
|
|
|
|
|
|
|
|
|
|
A module gains a real, enforceable permission the moment its manifest is written correctly and the
|
|
|
|
|
module is discovered — no separate registration step, no code to write beyond the manifest itself.
|
|
|
|
|
Core's own permissions and every module's permissions share one enforcement mechanism end to end:
|
|
|
|
|
one registry, one middleware, one explicit-grant HTTP API. The reserved `core.*` namespace and the
|
|
|
|
|
structural collision guarantee mean a module can never accidentally or deliberately shadow a
|
|
|
|
|
Core-owned permission without that attempt being caught, sanitized, and reported as exactly what it
|
|
|
|
|
is. The cost is the one explicitly deferred question this ADR leaves open — reinstall/reactivation
|
|
|
|
|
semantics for a permission whose module was previously removed — and the operational fact that
|
|
|
|
|
every module inventory change requires an API restart to take effect, which is acceptable for the
|
|
|
|
|
current deployment model but not the final word on it.
|
|
|
|
|
|
|
|
|
|
## Deferred
|
|
|
|
|
|
|
|
|
|
Module HTTP route-registration contracts (a module mounting its own endpoints); a
|
|
|
|
|
`ModuleRuntimeContext` authorization service; relationship-derived grant creation (a roster or
|
|
|
|
|
similar module operation using `sourceKind: "relationship"`, not the generic explicit-grant API);
|
|
|
|
|
reinstall/reactivation policy for a permission whose owning module was previously removed;
|
|
|
|
|
roles, groups, teams, and tenants; an owner-management HTTP endpoint; service accounts and API
|
|
|
|
|
tokens for non-browser automation; module installation/uninstallation state and hot reload; Surfaces
|
|
|
|
|
and UI; and workflow/event automation. None of these are implemented by, or required for, the
|
|
|
|
|
architecture this ADR records.
|