diff --git a/README.md b/README.md index f952bbc..fab23bb 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ surfaces. ## Current status -Phase 3 is complete; Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), and -Phase 4C (Core authorization) have landed; and Phase 5A (module authorization integration) is in -progress. The repository includes +Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), +Phase 4C (Core authorization), and Phase 5A (module authorization integration) have landed. The +repository includes public module contracts, independently versioned capabilities, static module manifests, deterministic automatic discovery, compiled server loading, a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic @@ -51,17 +51,21 @@ on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and [ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md). -Phase 5A (in progress) wires module-declared permissions into that same authorization system: a -version-3 module manifest declares permissions as complete `{id, baseline, description}` -declarations (never a bare string), and `apps/api` discovers every installed module and composes -one immutable platform permission registry — Core's own declarations plus every module's — before -constructing its database pool, Better Auth instance, or HTTP server. A module's declared -permission is then enforceable and grantable through the exact same `requirePermission` middleware -and `/api/v1/grants` HTTP API Core's own permissions already use, with `core.*` reserved and never +Phase 5A wires module-declared permissions into that same authorization system: a version-3 module +manifest declares permissions as complete `{id, baseline, description}` declarations (never a bare +string), and `apps/api` discovers every installed module and composes one immutable platform +permission registry — Core's own declarations plus every module's — before constructing its +database pool, Better Auth instance, or HTTP server. A module's declared permission is then +enforceable and grantable through the exact same `requirePermission` middleware and +`/api/v1/grants` HTTP API Core's own permissions already use, with `core.*` reserved and never claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission -composition. Module HTTP route registration and relationship-derived grant creation remain later -work. See [Authorization](docs/development/authorization.md#module-permission-composition) and -[Module development](docs/module-development/README.md). +composition. Installing, removing, or editing a module currently requires an API restart; removing +one makes its permission unavailable to new checks and new grants without deleting or revoking any +historical grant or audit row. Module HTTP route registration and relationship-derived grant +creation remain later work. See +[Authorization](docs/development/authorization.md#module-permission-composition), +[Module development](docs/module-development/README.md), and +[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md). ## Quick start diff --git a/apps/api/src/platform-permissions.ts b/apps/api/src/platform-permissions.ts index 155dd70..707a38e 100644 --- a/apps/api/src/platform-permissions.ts +++ b/apps/api/src/platform-permissions.ts @@ -62,14 +62,16 @@ export interface ComposeStartupPermissionRegistryOptions { readonly modulesDirectoryOverride?: string; /** Test-only escape hatch: supplies an already-discovered module list directly, skipping the * filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry` - * on it. Exists because a genuine exact permission-id collision between two modules — or between - * a module and Core — is unreachable through real discovery: module ids are unique (discovery - * itself rejects a duplicate) and every permission id is namespaced by its own module's id, so - * no two really-discovered modules can ever contribute the same permission id. Proving - * composition's own duplicate-rejection behavior, and that startup has created no pool before it - * runs, requires supplying deliberately conflicting declarations directly. Never set by the - * executable entry point; mutually exclusive with `modulesDirectoryOverride` in practice, since - * only one discovery-bypass is ever needed for a given scenario. + * on it. Exists because a genuine exact permission-id collision between two modules is + * unreachable through real discovery: module ids are unique (discovery itself rejects a + * duplicate) and every permission id is namespaced by its own module's id, so no two + * really-discovered modules can ever contribute the same permission id. Proving + * `PermissionRegistryError`'s own duplicate-rejection behavior in composition, and that startup + * has created no pool before it runs, requires supplying deliberately conflicting declarations + * directly. (A module-vs-Core collision, by contrast, *is* reachable through real discovery — a + * module whose own id is `"core"` — and surfaces as `ModulePermissionCompositionError`, not this + * case; `modulesDirectoryOverride` with a real fixture is what proves that one.) Never set by the + * executable entry point. */ readonly discoveredModulesOverride?: readonly DiscoveredModule[]; } diff --git a/core/src/modules/permissions.ts b/core/src/modules/permissions.ts index b1caaf1..dc82a93 100644 --- a/core/src/modules/permissions.ts +++ b/core/src/modules/permissions.ts @@ -45,15 +45,24 @@ export class ModulePermissionCompositionError extends Error { * * Declaration order is deterministic and meaningful for duplicate detection: Core's own * declarations first, then each module in the exact order it appears in `modules`, and within a - * module its declarations in manifest-array order. Duplicate exact permission ids — including a - * module colliding with a Core-owned id or with another module's — fail via the same - * `PermissionRegistryError` `createPermissionRegistry` already throws for any duplicate; this - * function does not reimplement or duplicate that detection. + * module its declarations in manifest-array order. Duplicate exact permission ids fail via the + * same `PermissionRegistryError` `createPermissionRegistry` already throws for any duplicate; + * this function does not reimplement or duplicate that detection. * * `core.*` is reserved for Core's own permissions. A discovered module contributing any * `core.`-prefixed permission id — regardless of that module's own id, and regardless of whether * it happens to exactly collide with an existing declaration — fails composition with - * `ModulePermissionCompositionError` rather than silently registering or silently colliding. + * `ModulePermissionCompositionError` rather than silently registering or silently colliding. This + * check runs before a declaration is ever added to the list `createPermissionRegistry` sees, so it + * is also what actually catches a module attempting to collide with a Core-owned id specifically: + * every Core-owned id is itself `core.`-prefixed, module ids may never contain a dot (so only a + * module whose own id is literally `"core"` can produce a `core.`-prefixed permission at all), and + * a genuine module-vs-module collision is structurally unreachable through real discovery — module + * ids are unique (discovery itself rejects a duplicate) and every permission id is namespaced by + * its own module's id. `PermissionRegistryError` therefore never fires for a real, validly + * discovered installed-module set; `createPermissionRegistry`'s duplicate rejection remains here as + * a defensive, unconditional guarantee regardless, and is exercised directly with deliberately + * conflicting test data rather than through a scenario real discovery could ever produce. */ export function composePlatformPermissionRegistry( modules: readonly DiscoveredModule[], diff --git a/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md b/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md index ac88c1f..0c3e073 100644 --- a/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md +++ b/docs/architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md @@ -19,6 +19,15 @@ registry, which conflicts with install-by-cloning discovery. ## Decision +> **Partial correction — see [ADR 0014](0014-module-permission-declarations-and-platform-registry.md).** +> `manifestVersion: 2` and the permission-string-array shape described immediately below (the +> `permissions` field of the static manifest) were superseded by ADR 0014's `manifestVersion: 3`, +> which represents each permission as a complete `{id, baseline, description}` declaration instead +> of a bare identifier string. Every other decision in this ADR — versioned capabilities, +> `defineModuleRuntime`, the compiled-runtime production loading boundary, and registration/lifecycle +> ordering — remains accepted and unchanged. This section is left as originally accepted, as the +> historical record of the manifest's prior shape. + `ekklesia.module.json` remains the one canonical static manifest and moves to `manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact semantic version. Required and optional capability dependencies map identifiers to semantic-version diff --git a/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md index 5f84daa..8634fee 100644 --- a/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md +++ b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md @@ -194,6 +194,13 @@ module permission discovery/registration (a mechanism for a module to contribute declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and remain later work. +> **Update — see [ADR 0014](0014-module-permission-declarations-and-platform-registry.md).** Module +> permission discovery/registration, deferred above, was implemented by ADR 0014: a module manifest +> declares permissions in the same `PermissionDeclaration` shape this ADR's registry uses, and +> `apps/api` composes Core's declarations with every installed module's into the one registry every +> request is enforced against. Roles/groups/teams/tenants, owner-management HTTP, and service +> accounts/API tokens remain deferred. + ## Alternatives - Representing platform-owner status as a special grant row (a synthetic `*` permission, or a diff --git a/docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md b/docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md new file mode 100644 index 0000000..100a1ee --- /dev/null +++ b/docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md @@ -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 `.`, 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 `.`; 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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 49a90cd..96c8c0b 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -22,3 +22,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted - [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted - [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted +- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 9a66ce5..7bc9243 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -1,24 +1,29 @@ # 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. +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 -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. +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; 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); there are no service accounts or API +tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot +yet mount its own HTTP 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 @@ -132,11 +137,16 @@ registry Core's own `core.authorization.grants.manage` permission is registered 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. +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. 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* @@ -477,8 +487,10 @@ pnpm test:migrations:integration # requires Docker ## Next phase -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. +Deliberately deferred, unrelated slices — see +[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred) +for the complete list: 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), 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.