feat: integrate module authorization #11

Merged
Zayin_Admin merged 4 commits from phase-5/module-authorization-integration into main 2026-07-24 23:05:43 +00:00
8 changed files with 329 additions and 52 deletions
Showing only changes of commit 9fa051be61 - Show all commits
+17 -13
View File
@@ -6,9 +6,9 @@ surfaces.
## Current status ## Current status
Phase 3 is complete; Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), and Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
Phase 4C (Core authorization) have landed; and Phase 5A (module authorization integration) is in Phase 4C (Core authorization), and Phase 5A (module authorization integration) have landed. The
progress. The repository includes repository includes
public module contracts, independently versioned capabilities, static module manifests, public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry, deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic 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 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). [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 Phase 5A wires module-declared permissions into that same authorization system: a version-3 module
version-3 module manifest declares permissions as complete `{id, baseline, description}` manifest declares permissions as complete `{id, baseline, description}` declarations (never a bare
declarations (never a bare string), and `apps/api` discovers every installed module and composes string), and `apps/api` discovers every installed module and composes one immutable platform
one immutable platform permission registry — Core's own declarations plus every module's — before permission registry — Core's own declarations plus every module's — before constructing its
constructing its database pool, Better Auth instance, or HTTP server. A module's declared database pool, Better Auth instance, or HTTP server. A module's declared permission is then
permission is then enforceable and grantable through the exact same `requirePermission` middleware enforceable and grantable through the exact same `requirePermission` middleware and
and `/api/v1/grants` HTTP API Core's own permissions already use, with `core.*` reserved and never `/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 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 composition. Installing, removing, or editing a module currently requires an API restart; removing
work. See [Authorization](docs/development/authorization.md#module-permission-composition) and one makes its permission unavailable to new checks and new grants without deleting or revoking any
[Module development](docs/module-development/README.md). 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 ## Quick start
+10 -8
View File
@@ -62,14 +62,16 @@ export interface ComposeStartupPermissionRegistryOptions {
readonly modulesDirectoryOverride?: string; readonly modulesDirectoryOverride?: string;
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the /** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry` * 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 * on it. Exists because a genuine exact permission-id collision between two modules is
* a module and Core — is unreachable through real discovery: module ids are unique (discovery * unreachable through real discovery: module ids are unique (discovery itself rejects a
* itself rejects a duplicate) and every permission id is namespaced by its own module's id, so * duplicate) and every permission id is namespaced by its own module's id, so no two
* no two really-discovered modules can ever contribute the same permission id. Proving * 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 * `PermissionRegistryError`'s own duplicate-rejection behavior in composition, and that startup
* runs, requires supplying deliberately conflicting declarations directly. Never set by the * has created no pool before it runs, requires supplying deliberately conflicting declarations
* executable entry point; mutually exclusive with `modulesDirectoryOverride` in practice, since * directly. (A module-vs-Core collision, by contrast, *is* reachable through real discovery — a
* only one discovery-bypass is ever needed for a given scenario. * 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[]; readonly discoveredModulesOverride?: readonly DiscoveredModule[];
} }
+14 -5
View File
@@ -45,15 +45,24 @@ export class ModulePermissionCompositionError extends Error {
* *
* Declaration order is deterministic and meaningful for duplicate detection: Core's own * 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 * 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 its declarations in manifest-array order. Duplicate exact permission ids fail via the
* module colliding with a Core-owned id or with another module's — fail via the same * same `PermissionRegistryError` `createPermissionRegistry` already throws for any duplicate;
* `PermissionRegistryError` `createPermissionRegistry` already throws for any duplicate; this * this function does not reimplement or duplicate that detection.
* function does not reimplement or duplicate that detection.
* *
* `core.*` is reserved for Core's own permissions. A discovered module contributing any * `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 * `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 * 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( export function composePlatformPermissionRegistry(
modules: readonly DiscoveredModule[], modules: readonly DiscoveredModule[],
@@ -19,6 +19,15 @@ registry, which conflicts with install-by-cloning discovery.
## Decision ## 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 `ekklesia.module.json` remains the one canonical static manifest and moves to
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact `manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
semantic version. Required and optional capability dependencies map identifiers to semantic-version semantic version. Required and optional capability dependencies map identifiers to semantic-version
@@ -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 declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and
remain later work. 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 ## Alternatives
- Representing platform-owner status as a special grant row (a synthetic `*` permission, or a - Representing platform-owner status as a special grant row (a synthetic `*` permission, or a
@@ -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.
+1
View File
@@ -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 - [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 - [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 - [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
+38 -26
View File
@@ -1,24 +1,29 @@
# Authorization # Authorization
Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into Phase 4C establishes Core's authorization foundation and wires it into `apps/api` over HTTP. Phase
`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the 5A extends that same system so an installed module's declared permissions are enforced and
platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it. grantable through the identical mechanism as Core's own. This page covers all of it: the
See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the schema/evaluator/store/registry Core owns, the platform-owner bootstrap operation, the
HTTP transport it is mounted into, and [ADR enforcement/endpoints `apps/api` exposes, and how module manifests contribute to the one platform
0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable permission registry. See [Identity](identity.md) for the identity foundation this builds on,
architectural decisions this page describes in more implementation detail. [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 ## What this is, and is not
After this slice, an operator can bootstrap exactly one platform owner for a deployment, a An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone
platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any
grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of registered permission, Core-owned or module-owned; and every enforced route fails closed on
silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants; infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage
there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or
exposed at all in this slice); there are no service accounts or API tokens, so only re-granting platform-owner status is not exposed at all); there are no service accounts or API
cookie-authenticated browser callers can reach the mutation routes; and relationship-derived tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot
grants (for example, automatic access from a roster assignment) cannot yet be created by anything yet mount its own HTTP routes; and relationship-derived grants (for example, automatic access from
in this repository — the generic grants API structurally cannot create or revoke them either. 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 ## 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 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 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 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, grants (a well-formed but unregistered permission denies exactly as before, including on
revokes, or otherwise modifies a historical grant or audit row already recorded for that `POST /api/v1/grants`, which checks the registry explicitly before calling the store); it never
permission — a previously granted, still-unrevoked explicit grant for a now-uninstalled module's deletes, revokes, or otherwise modifies a historical grant or audit row already recorded for that
permission remains visible through `GET /api/v1/grants` and simply stops being something anyone permission. A previously granted, still-unrevoked explicit grant for a now-uninstalled module's
can newly grant, revoke, or have authorized against. 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 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* 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 ## Next phase
Phase 5A, pass 3: deferred from this slice — module HTTP route-registration contracts (a module Deliberately deferred, unrelated slices — see
mounting its own endpoints), a module runtime-context authorization service, relationship-derived [ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
grant creation itself (a roster or similar module operation, not the generic grants API), for the complete list: module HTTP route-registration contracts (a module mounting its own
roles/groups/teams/tenants, an owner-management HTTP endpoint, and service accounts/API tokens for endpoints), a module runtime-context authorization service, relationship-derived grant creation
non-browser automation. 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.