feat: integrate module authorization #11
@@ -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
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+233
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user