feat: declare module permissions
Establishes the one canonical PermissionDeclaration contract (packages/contracts/src/authorization.ts) — id/baseline/description, strict, unknown-property-rejecting — consumed by both Core's authorization registry and module manifests, replacing Core's previously separate interface. Bumps the canonical module manifest to manifestVersion 3: permissions become complete declarations instead of bare identifier strings, with manifest-owned namespace and duplicate-id enforcement. Version 2 and bare string-array permissions are rejected outright; there is no compatibility adapter. Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts), a pure, synchronous composer that builds one platform permission registry from Core's own declarations plus every already-discovered module's, in deterministic order, reusing createPermissionRegistry and rejecting any module-contributed permission in the reserved core.* namespace. core/src/authorization remains independent of core/src/modules; only the module integration layer depends on authorization. Updates the module template, module-development docs, authorization docs, and existing manifest fixtures for manifestVersion 3. Does not wire the combined registry into apps/api — that is pass 2.
This commit is contained in:
@@ -31,10 +31,19 @@ permission registry, the bootstrap operation, and HTTP enforcement.
|
||||
|
||||
## Permission registry
|
||||
|
||||
`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable,
|
||||
composition-time-built `PermissionRegistry` distinguishing a *registered declaration*
|
||||
(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties
|
||||
matter:
|
||||
`PermissionDeclaration` (`packages/contracts/src/authorization.ts`) is the one authoritative,
|
||||
strict Zod-validated shape of a *registered declaration* — exactly `{id, baseline, description}`,
|
||||
nothing more — shared by Core's authorization registry and a module manifest's `permissions` array.
|
||||
Core does not declare its own separate interface for this; `core/src/authorization/permissions.ts`
|
||||
imports and re-exports the contracts type so existing `@ekklesia/core/authorization` consumers keep
|
||||
importing it from the same place. `id` is a branded `PermissionId`, `baseline` is
|
||||
`public`/`authenticated`/`restricted`, and `description` is required, trimmed, non-empty operator
|
||||
and audit-facing text up to 240 characters — informational only; nothing in authorization reads or
|
||||
interprets it.
|
||||
|
||||
`core/src/authorization/permissions.ts` defines the smallest possible registry on top of that
|
||||
shape: an immutable, composition-time-built `PermissionRegistry` distinguishing a *registered*
|
||||
declaration from a merely well-formed `PermissionId` string. Two properties matter:
|
||||
|
||||
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
|
||||
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
|
||||
@@ -44,14 +53,52 @@ matter:
|
||||
and return `undefined`/`false` for anything malformed or simply not registered — there is no
|
||||
"trust this permission id because it looks right" path.
|
||||
|
||||
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission:
|
||||
Permission matching is always exact string equality, both in the registry and in the evaluator's
|
||||
grant lookup — there is no wildcard, prefix, or namespace-inheritance matching anywhere; the
|
||||
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
|
||||
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
|
||||
|
||||
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and
|
||||
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
|
||||
same declarations exported separately in deeply immutable form, for the module composition helper
|
||||
below to build on without reconstructing Core's own list:
|
||||
|
||||
| Permission | Baseline | Description |
|
||||
| --- | --- | --- |
|
||||
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
||||
|
||||
Module-owned permission declarations and a discovery/composition path for them are out of scope
|
||||
for this slice (deferred to the Module SDK work).
|
||||
## Module permission composition
|
||||
|
||||
A module manifest's `permissions` array (`manifestVersion: 3`,
|
||||
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
||||
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
||||
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
||||
anything. `composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) is the trusted
|
||||
composer: given an already-discovered module list (for example `discoverModules`'s result), it
|
||||
builds one combined `PermissionRegistry` from `CORE_PERMISSION_DECLARATIONS` followed by every
|
||||
module's declarations, in discovery order, using the same `createPermissionRegistry` constructor —
|
||||
not a second registry implementation. Declaration order is deterministic: Core first, then each
|
||||
module in the order it appears in the supplied list, then each module's own declarations in
|
||||
manifest-array order. A duplicate exact permission id anywhere in that combined list fails
|
||||
composition the same way a duplicate would fail `createPermissionRegistry` directly — since a
|
||||
permission id must start with its own module's id, this is only reachable across two entries that
|
||||
share the same module id, which real discovery already rules out (`MODULE_ID_DUPLICATE`);
|
||||
composition still re-derives the check rather than trusting that guarantee alone.
|
||||
|
||||
`core.*` is reserved for Core's own permissions. A discovered module contributing any
|
||||
`core.`-prefixed permission id fails composition with `ModulePermissionCompositionError`
|
||||
(`MODULE_PERMISSION_RESERVED_NAMESPACE`) before duplicate detection ever runs — since every
|
||||
Core-owned id is itself `core.`-prefixed, this is what actually catches a module attempting to
|
||||
collide with a Core-owned id specifically, regardless of that module's own id and regardless of
|
||||
whether the id happens to exactly match an existing declaration.
|
||||
|
||||
Composition itself performs no filesystem, environment, database, network, or module-code
|
||||
operation — it is pure, synchronous, in-memory composition over data a caller already discovered
|
||||
and validated (`core/src/authorization` and `core/src/modules` remain independent of each other in
|
||||
the other direction: authorization never imports module discovery or runtime, only the module
|
||||
integration layer depends on authorization). This pass stops at producing the combined registry;
|
||||
wiring it into `apps/api` in place of `CORE_PERMISSION_REGISTRY`, and any module-owned HTTP
|
||||
enforcement built on it, is later work.
|
||||
|
||||
### Two layers, not one: shape versus registration
|
||||
|
||||
@@ -287,7 +334,19 @@ there are or whether the failure happened during startup.
|
||||
## Tests
|
||||
|
||||
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
|
||||
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY` contents.
|
||||
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY`/`CORE_PERMISSION_DECLARATIONS` contents
|
||||
and immutability.
|
||||
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
||||
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
||||
rejection, malformed permission id rejection.
|
||||
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 3` acceptance and version
|
||||
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
||||
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
||||
path, and default-empty-list isolation across separate parses.
|
||||
- `core/tests/module-permissions.test.ts` — `composePlatformPermissionRegistry`: Core-only and
|
||||
Core-plus-modules composition, deterministic ordering, exact baseline/description preservation,
|
||||
duplicate rejection, reserved `core.*` namespace rejection, unknown-permission denial once a
|
||||
module is absent from the supplied list, and input/output immutability with no I/O.
|
||||
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
|
||||
per-domain database tests).
|
||||
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`
|
||||
|
||||
@@ -48,7 +48,7 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 2,
|
||||
"manifestVersion": 3,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
@@ -77,7 +77,13 @@ without executing module code:
|
||||
"version": "1.0.0"
|
||||
}
|
||||
],
|
||||
"permissions": ["calendar.events.read"],
|
||||
"permissions": [
|
||||
{
|
||||
"id": "calendar.events.read",
|
||||
"baseline": "restricted",
|
||||
"description": "Read calendar events."
|
||||
}
|
||||
],
|
||||
"storage": {
|
||||
"buckets": [
|
||||
{
|
||||
@@ -99,6 +105,22 @@ has its own exact semantic version because its public contract can evolve indepe
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 3` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 2 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||
each baseline means to the evaluator), and a required, non-blank `description` up to 240 characters
|
||||
for operators and audit review only — Core's authorization layer never reads or interprets it.
|
||||
Declaring a permission here is necessary but not sufficient for it to be enforceable: the manifest
|
||||
only proves the declaration is well-formed and namespaced correctly, and does not itself register
|
||||
anything with Core's authorization registry (see
|
||||
[Authorization](../development/authorization.md#module-permission-composition) for how Core composes
|
||||
every discovered module's declarations, alongside its own, into the one platform permission
|
||||
registry). A duplicate permission id within one manifest, or a permission id outside the module's
|
||||
own namespace, fails validation the same way a duplicate or misnamed capability id does.
|
||||
|
||||
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
|
||||
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package,
|
||||
and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
||||
|
||||
Reference in New Issue
Block a user