From d9dd409aa3c4ccd7293d88c043f610dcd99fa3b7 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 24 Jul 2026 12:25:00 -0500 Subject: [PATCH 1/4] feat: declare module permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/src/authorization/permissions.ts | 30 +-- core/src/modules/discovery.ts | 18 +- core/src/modules/index.ts | 1 + core/src/modules/permissions.ts | 73 ++++++ core/tests/authorization-permissions.test.ts | 19 ++ core/tests/core-migration-discovery.test.ts | 2 +- core/tests/doctor.test.ts | 2 +- core/tests/module-discovery.test.ts | 24 +- core/tests/module-migration-discovery.test.ts | 2 +- core/tests/module-permissions.test.ts | 223 ++++++++++++++++++ core/tests/module-runtime.test.ts | 2 +- core/tests/module-schema-discovery.test.ts | 2 +- docs/development/authorization.md | 75 +++++- docs/module-development/README.md | 26 +- packages/contracts/src/authorization.ts | 39 +++ packages/contracts/src/module-manifest.ts | 21 +- .../contracts/tests/authorization.test.ts | 84 +++++++ .../contracts/tests/module-manifest.test.ts | 157 +++++++++++- templates/module/ekklesia.module.json | 10 +- templates/module/tests/manifest.test.ts | 7 + 20 files changed, 765 insertions(+), 52 deletions(-) create mode 100644 core/src/modules/permissions.ts create mode 100644 core/tests/module-permissions.test.ts diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts index 66e52ce..f5242c1 100644 --- a/core/src/authorization/permissions.ts +++ b/core/src/authorization/permissions.ts @@ -1,25 +1,18 @@ import { - type PermissionBaseline, + type PermissionDeclaration, type PermissionId, parsePermissionId, safeParsePermissionId, } from "@ekklesia/contracts"; /** - * A registered permission declaration: the trusted pairing of a permission identifier with its - * one baseline. This is what distinguishes a *registered* permission from a merely well-formed - * `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an - * explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller - * (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that - * baseline is never client-supplied or guessed. + * Core's registry consumes the one shared `PermissionDeclaration` shape + * (`packages/contracts/src/authorization.ts`) rather than declaring its own interface — a module + * manifest's `permissions` array parses into the exact same type, so a declaration a module ships + * and a declaration Core registers are provably interchangeable. Re-exported here so existing + * `@ekklesia/core/authorization` consumers keep importing it from this package. */ -export interface PermissionDeclaration { - readonly id: PermissionId; - readonly baseline: PermissionBaseline; - /** Human-readable, for operators and audit review only — never interpreted by the evaluator or - * by enforcement. */ - readonly description: string; -} +export type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts"; export interface PermissionRegistry { readonly declarations: readonly PermissionDeclaration[]; @@ -89,7 +82,14 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId( "core.authorization.grants.manage", ); -const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([ +/** + * Core's own declarations, exported in deeply immutable form so a trusted composer (the module + * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from + * exactly these plus every discovered module's declarations, in a fixed, deterministic order, + * without reconstructing or guessing Core's own list. `CORE_PERMISSION_REGISTRY` below remains the + * registry Core-only code and existing tests use directly. + */ +export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([ Object.freeze({ baseline: "restricted" as const, description: "Create, list, and revoke another user's explicit authorization grants.", diff --git a/core/src/modules/discovery.ts b/core/src/modules/discovery.ts index ca497ed..7c133bf 100644 --- a/core/src/modules/discovery.ts +++ b/core/src/modules/discovery.ts @@ -47,13 +47,19 @@ export interface ResolvedModuleEntrypoints { readonly jobs?: string; } -type DeepReadonly = T extends (...arguments_: never[]) => unknown +// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types, +// which structurally satisfy `T extends object` — without this check first, the mapped-type +// branch below would incorrectly expand a branded string into `String.prototype`'s member shape +// and lose the brand entirely, rather than leaving an already-immutable primitive untouched. +type DeepReadonly = T extends string | number | boolean | bigint | symbol | null | undefined ? T - : T extends readonly (infer Item)[] - ? readonly DeepReadonly[] - : T extends object - ? { readonly [Key in keyof T]: DeepReadonly } - : T; + : T extends (...arguments_: never[]) => unknown + ? T + : T extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; export type ReadonlyModuleManifest = DeepReadonly; diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts index 085b2e4..b324e7e 100644 --- a/core/src/modules/index.ts +++ b/core/src/modules/index.ts @@ -1,4 +1,5 @@ export * from "./discovery.js"; export * from "./migration-discovery.js"; +export * from "./permissions.js"; export * from "./runtime.js"; export * from "./schema-discovery.js"; diff --git a/core/src/modules/permissions.ts b/core/src/modules/permissions.ts new file mode 100644 index 0000000..b1caaf1 --- /dev/null +++ b/core/src/modules/permissions.ts @@ -0,0 +1,73 @@ +import { + CORE_PERMISSION_DECLARATIONS, + createPermissionRegistry, + type PermissionDeclaration, + type PermissionRegistry, +} from "../authorization/index.js"; +import type { DiscoveredModule } from "./discovery.js"; + +const CORE_PERMISSION_NAMESPACE_PREFIX = "core."; + +export type ModulePermissionCompositionErrorCode = "MODULE_PERMISSION_RESERVED_NAMESPACE"; + +/** Thrown only at composition time, never per-request. Carries the offending module and + * permission id deliberately — like `PermissionRegistryError`, this is a startup-time + * configuration error, not a runtime response, and neither value is sensitive. Never carries a + * module directory, manifest path, caught error, or stack detail. */ +export class ModulePermissionCompositionError extends Error { + readonly code: ModulePermissionCompositionErrorCode; + readonly moduleId: string; + readonly permissionId: string; + + constructor(moduleId: string, permissionId: string) { + super( + `Module "${moduleId}" cannot declare permission "${permissionId}": the "core." namespace is reserved for Core's own permissions.`, + ); + this.name = "ModulePermissionCompositionError"; + this.code = "MODULE_PERMISSION_RESERVED_NAMESPACE"; + this.moduleId = moduleId; + this.permissionId = permissionId; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +/** + * Core's module-integration-layer composition helper: builds the one platform permission + * registry from Core's own declarations (`CORE_PERMISSION_DECLARATIONS`) plus every already + * discovered module's manifest-declared permissions. Deliberately lives in `core/src/modules`, + * not `core/src/authorization` — the dependency runs one way only, this layer depends on + * authorization's `createPermissionRegistry`, and authorization never imports module discovery + * or runtime. This is pure, synchronous, in-memory composition over already-discovered, + * already-validated data: no filesystem, environment, database, network, or module-code + * execution happens here, and the caller (not this function) is responsible for having already + * produced a complete, successfully discovered module list (for example via `discoverModules`). + * + * 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. + * + * `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. + */ +export function composePlatformPermissionRegistry( + modules: readonly DiscoveredModule[], +): PermissionRegistry { + const declarations: PermissionDeclaration[] = [...CORE_PERMISSION_DECLARATIONS]; + + for (const module of modules) { + for (const declaration of module.manifest.permissions) { + if (declaration.id.startsWith(CORE_PERMISSION_NAMESPACE_PREFIX)) { + throw new ModulePermissionCompositionError(module.manifest.id, declaration.id); + } + declarations.push(declaration); + } + } + + return createPermissionRegistry(declarations); +} diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts index fb4574a..d6574aa 100644 --- a/core/tests/authorization-permissions.test.ts +++ b/core/tests/authorization-permissions.test.ts @@ -2,6 +2,7 @@ import { parsePermissionId } from "@ekklesia/contracts"; import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_PERMISSION_DECLARATIONS, CORE_PERMISSION_REGISTRY, createPermissionRegistry, PermissionRegistryError, @@ -92,3 +93,21 @@ describe("CORE_PERMISSION_REGISTRY", () => { expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1); }); }); + +describe("CORE_PERMISSION_DECLARATIONS", () => { + it("is Core's own declaration list, exported separately from the registry in deeply immutable form", () => { + expect(CORE_PERMISSION_DECLARATIONS).toEqual([ + { + baseline: "restricted", + description: "Create, list, and revoke another user's explicit authorization grants.", + id: CORE_AUTHORIZATION_GRANTS_MANAGE, + }, + ]); + expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true); + expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true); + }); + + it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => { + expect(CORE_PERMISSION_DECLARATIONS).toEqual(CORE_PERMISSION_REGISTRY.declarations); + }); +}); diff --git a/core/tests/core-migration-discovery.test.ts b/core/tests/core-migration-discovery.test.ts index 6633404..76b8a52 100644 --- a/core/tests/core-migration-discovery.test.ts +++ b/core/tests/core-migration-discovery.test.ts @@ -91,7 +91,7 @@ async function createModule( writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 9854d2c..2c8f505 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -39,7 +39,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule { }, entrypoints: { server: "./dist/server.js" }, id, - manifestVersion: 2, + manifestVersion: 3, name: `${id} module`, permissions: [], storage: { buckets: [] }, diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index 2db18e9..772750e 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -31,7 +31,7 @@ async function createModulesDirectory(): Promise { function manifestFor(id: string): Record { return { - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", @@ -40,7 +40,9 @@ function manifestFor(id: string): Record { moduleSdk: "^0.1.0", }, capabilities: [{ id: `${id}.records`, version: "1.0.0" }], - permissions: [`${id}.records.read`], + permissions: [ + { id: `${id}.records.read`, baseline: "restricted", description: "Read records." }, + ], entrypoints: { server: "./dist/server/index.js", }, @@ -151,6 +153,16 @@ describe("module discovery", () => { expect(Object.isFrozen(modules[1]?.manifest)).toBe(true); const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>; expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError); + + expect(modules[1]?.manifest.permissions).toEqual([ + { id: "calendar.records.read", baseline: "restricted", description: "Read records." }, + ]); + expect(Object.isFrozen(modules[1]?.manifest.permissions)).toBe(true); + expect(Object.isFrozen(modules[1]?.manifest.permissions[0])).toBe(true); + const mutablePermissions = modules[1]?.manifest.permissions as unknown as Array<{ + id: string; + }>; + expect(() => mutablePermissions.push({ id: "tampered" })).toThrow(TypeError); }); it("aggregates malformed packages and manifests without partial discovery", async () => { @@ -215,7 +227,9 @@ describe("module discovery", () => { ...manifestFor("one"), id: "shared", capabilities: [{ id: "shared.records", version: "1.0.0" }], - permissions: ["shared.records.read"], + permissions: [ + { id: "shared.records.read", baseline: "restricted", description: "Read records." }, + ], }, packageJson: { name: "@test/duplicate", @@ -229,7 +243,9 @@ describe("module discovery", () => { ...manifestFor("two"), id: "shared", capabilities: [{ id: "shared.records", version: "1.0.0" }], - permissions: ["shared.records.read"], + permissions: [ + { id: "shared.records.read", baseline: "restricted", description: "Read records." }, + ], }, packageJson: { name: "@test/duplicate", diff --git a/core/tests/module-migration-discovery.test.ts b/core/tests/module-migration-discovery.test.ts index 3f22127..58d47e4 100644 --- a/core/tests/module-migration-discovery.test.ts +++ b/core/tests/module-migration-discovery.test.ts @@ -65,7 +65,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts new file mode 100644 index 0000000..8652c82 --- /dev/null +++ b/core/tests/module-permissions.test.ts @@ -0,0 +1,223 @@ +import { type PermissionBaseline, parseModuleManifest } from "@ekklesia/contracts"; +import { describe, expect, it } from "vitest"; +import { + CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_PERMISSION_DECLARATIONS, + PermissionRegistryError, +} from "../src/authorization/index.js"; +import type { DiscoveredModule } from "../src/modules/discovery.js"; +import { + composePlatformPermissionRegistry, + ModulePermissionCompositionError, +} from "../src/modules/permissions.js"; + +interface PermissionInput { + readonly id: string; + readonly baseline: PermissionBaseline; + readonly description: string; +} + +/** A minimal, in-memory `DiscoveredModule` double — no filesystem, no real discovery. The manifest + * is still built through `parseModuleManifest`, so a fixture that would not actually pass manifest + * validation cannot silently misrepresent what real discovery would ever produce. Only `manifest` + * is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */ +function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule { + const manifest = parseModuleManifest({ + manifestVersion: 3, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + permissions, + entrypoints: { + // Deliberately a path nothing will ever resolve, proving composition never imports or + // otherwise touches a module's server entrypoint. + server: "./dist/does-not-exist/index.js", + }, + }); + return { + directory: `/modules/${id}`, + entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, + manifest, + manifestPath: `/modules/${id}/ekklesia.module.json`, + packageName: `@test/${id}`, + }; +} + +describe("composePlatformPermissionRegistry", () => { + it("composes a Core-only registry when no modules are supplied", () => { + const registry = composePlatformPermissionRegistry([]); + + expect(registry.declarations).toEqual(CORE_PERMISSION_DECLARATIONS); + expect(registry.has(CORE_AUTHORIZATION_GRANTS_MANAGE)).toBe(true); + }); + + it("composes Core and multiple discovered modules' declarations into one registry", () => { + const calendar = fakeModule("calendar", [ + { id: "calendar.events.read", baseline: "restricted", description: "Read events." }, + ]); + const people = fakeModule("people", [ + { id: "people.directory.read", baseline: "authenticated", description: "Read directory." }, + ]); + + const registry = composePlatformPermissionRegistry([calendar, people]); + + expect(registry.has(CORE_AUTHORIZATION_GRANTS_MANAGE)).toBe(true); + expect(registry.has("calendar.events.read")).toBe(true); + expect(registry.has("people.directory.read")).toBe(true); + expect(registry.declarations).toHaveLength(3); + }); + + it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => { + const calendar = fakeModule("calendar", [ + { id: "calendar.events.read", baseline: "restricted", description: "Read events." }, + { id: "calendar.events.write", baseline: "restricted", description: "Write events." }, + ]); + const people = fakeModule("people", [ + { id: "people.directory.read", baseline: "authenticated", description: "Read directory." }, + ]); + + const registry = composePlatformPermissionRegistry([calendar, people]); + + expect(registry.declarations.map((declaration) => declaration.id)).toEqual([ + CORE_AUTHORIZATION_GRANTS_MANAGE, + "calendar.events.read", + "calendar.events.write", + "people.directory.read", + ]); + }); + + it.each([ + "public", + "authenticated", + "restricted", + ] as const)("preserves the exact %s baseline and description of a module declaration", (baseline) => { + const calendar = fakeModule("calendar", [ + { id: "calendar.events.read", baseline, description: "Read calendar events." }, + ]); + + const registry = composePlatformPermissionRegistry([calendar]); + + expect(registry.get("calendar.events.read")).toEqual({ + id: "calendar.events.read", + baseline, + description: "Read calendar events.", + }); + }); + + it("still fails closed for a permission no module or Core ever declared", () => { + const registry = composePlatformPermissionRegistry([]); + + expect(registry.has("calendar.events.read")).toBe(false); + expect(registry.get("calendar.events.read")).toBeUndefined(); + }); + + it("fails composition on an exact duplicate permission id supplied by two module entries", () => { + // A permission id must start with its own module's id, so a genuine cross-module collision is + // only reachable if the supplied list itself contains the same module id twice — real + // discovery already guarantees module-id uniqueness (`MODULE_ID_DUPLICATE`), but composition + // re-derives duplicate detection from the flat declaration list regardless, rather than + // trusting that upstream guarantee alone. + const first = fakeModule("calendar", [ + { id: "calendar.events.read", baseline: "restricted", description: "Read events." }, + ]); + const second = fakeModule("calendar", [ + { id: "calendar.events.read", baseline: "public", description: "Also read events." }, + ]); + + expect(() => composePlatformPermissionRegistry([first, second])).toThrow( + PermissionRegistryError, + ); + }); + + it("rejects a module contributing a permission in the reserved core.* namespace, without leaking a module path", () => { + // Only a module whose own id is literally "core" can produce a "core."-prefixed permission + // that still passes the manifest's own module-namespace check — this proves composition + // catches that case rather than trusting the manifest's self-consistency alone. + const impostor = fakeModule("core", [ + { id: "core.rogue.permission", baseline: "restricted", description: "Not really Core's." }, + ]); + + let captured: unknown; + try { + composePlatformPermissionRegistry([impostor]); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(ModulePermissionCompositionError); + expect(captured).toMatchObject({ + code: "MODULE_PERMISSION_RESERVED_NAMESPACE", + moduleId: "core", + permissionId: "core.rogue.permission", + }); + const publicText = String(captured); + expect(publicText).not.toContain("/modules/core"); + expect(publicText).not.toContain("ekklesia.module.json"); + }); + + it("rejects a reserved-namespace collision even when it would not otherwise duplicate an existing id", () => { + const impostor = fakeModule("core", [ + { + id: "core.authorization.grants.manage", + baseline: "restricted", + description: "Attempting to shadow Core's own permission.", + }, + ]); + + expect(() => composePlatformPermissionRegistry([impostor])).toThrow( + ModulePermissionCompositionError, + ); + }); + + it("removes a module's permissions from the resulting registry once that module is absent from the supplied list", () => { + const calendar = fakeModule("calendar", [ + { id: "calendar.events.read", baseline: "restricted", description: "Read events." }, + ]); + + const withCalendar = composePlatformPermissionRegistry([calendar]); + expect(withCalendar.has("calendar.events.read")).toBe(true); + + const withoutCalendar = composePlatformPermissionRegistry([]); + expect(withoutCalendar.has("calendar.events.read")).toBe(false); + }); + + it("performs no I/O: composition is synchronous and never imports a module's entrypoint", () => { + const calendar = fakeModule("calendar", [ + { id: "calendar.events.read", baseline: "restricted", description: "Read events." }, + ]); + + // A synchronous return (not a Promise) from a fixture whose entrypoint deliberately resolves + // to nothing on disk is direct evidence no filesystem or module-code operation occurred. + const result = composePlatformPermissionRegistry([calendar]); + expect(result).not.toBeInstanceOf(Promise); + expect(result.has("calendar.events.read")).toBe(true); + }); + + it("does not mutate the supplied module list or Core's own declaration list", () => { + const calendar = fakeModule("calendar", [ + { id: "calendar.events.read", baseline: "restricted", description: "Read events." }, + ]); + const modules = Object.freeze([calendar]); + const coreDeclarationsBefore = [...CORE_PERMISSION_DECLARATIONS]; + + composePlatformPermissionRegistry(modules); + + expect(modules).toEqual([calendar]); + expect(CORE_PERMISSION_DECLARATIONS).toEqual(coreDeclarationsBefore); + }); + + it("returns a registry and declarations that remain immutable, including module-contributed ones", () => { + const calendar = fakeModule("calendar", [ + { id: "calendar.events.read", baseline: "restricted", description: "Read events." }, + ]); + + const registry = composePlatformPermissionRegistry([calendar]); + + expect(Object.isFrozen(registry)).toBe(true); + expect(Object.isFrozen(registry.declarations)).toBe(true); + for (const declaration of registry.declarations) { + expect(Object.isFrozen(declaration)).toBe(true); + } + }); +}); diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts index 71edd82..28b02d9 100644 --- a/core/tests/module-runtime.test.ts +++ b/core/tests/module-runtime.test.ts @@ -43,7 +43,7 @@ function manifestFor( } = {}, ): Record { return { - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", diff --git a/core/tests/module-schema-discovery.test.ts b/core/tests/module-schema-discovery.test.ts index e7374df..1a760f9 100644 --- a/core/tests/module-schema-discovery.test.ts +++ b/core/tests/module-schema-discovery.test.ts @@ -41,7 +41,7 @@ function manifestFor( options: { dependencies?: Record; schema?: string | false } = {}, ): Record { return { - manifestVersion: 2, + manifestVersion: 3, id, name: `${id} module`, version: "0.1.0", diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 177eda3..8a1f3bd 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -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` diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 6dcbddf..53947a7 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -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 `.`, 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 diff --git a/packages/contracts/src/authorization.ts b/packages/contracts/src/authorization.ts index 85aa1e3..a1d8e03 100644 --- a/packages/contracts/src/authorization.ts +++ b/packages/contracts/src/authorization.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { permissionIdSchema } from "./identifiers.js"; import type { EkklesiaUserId } from "./identity.js"; import { parseEkklesiaUserId } from "./identity.js"; @@ -51,6 +52,44 @@ export const permissionBaselineSchema = z.enum(["public", "authenticated", "rest export type PermissionBaseline = z.infer; +/** + * The one authoritative shape of a *registered* permission declaration — what both Core's + * authorization registry (`core/src/authorization/permissions.ts`) and a module manifest's + * `permissions` array parse into, so a permission a module declares and a permission Core's + * registry enforces are provably the same structure, never two independently maintained + * interfaces that could quietly drift apart. `.strict()` rejects any property beyond these three — + * there is no room for a module or a future caller to smuggle extra, unvalidated metadata into a + * declaration Core treats as trusted. + * + * This schema says nothing about *registration*: parsing a value into a `PermissionDeclaration` + * only proves it is shaped correctly, not that any registry has accepted it. Registration remains + * exclusively `PermissionRegistry`'s responsibility (`core/src/authorization/permissions.ts`) — + * built by composing exactly the declarations a trusted caller supplies, Core's own plus every + * discovered module's. + */ +export const permissionDeclarationSchema = z + .object({ + id: permissionIdSchema, + baseline: permissionBaselineSchema, + /** Operator/audit-facing text only. Authorization never reads, parses, or otherwise + * interprets this value — changing it can never change what a permission allows. */ + description: z.string().trim().min(1).max(240), + }) + .strict(); + +export type PermissionDeclarationInput = z.input; +export type PermissionDeclaration = z.output; + +export function parsePermissionDeclaration(input: unknown): PermissionDeclaration { + return permissionDeclarationSchema.parse(input); +} + +export function safeParsePermissionDeclaration( + input: unknown, +): z.ZodSafeParseResult { + return permissionDeclarationSchema.safeParse(input); +} + /** * Distinguishes a grant an operator/system created directly (`explicit`) from one a module * created as a side effect of its own business operation, attributed to some source the module diff --git a/packages/contracts/src/module-manifest.ts b/packages/contracts/src/module-manifest.ts index 322939b..24310ba 100644 --- a/packages/contracts/src/module-manifest.ts +++ b/packages/contracts/src/module-manifest.ts @@ -1,10 +1,10 @@ import { z } from "zod"; +import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js"; import { capabilityIdSchema, logicalBucketIdSchema, moduleIdSchema, packageRelativePathSchema, - permissionIdSchema, } from "./identifiers.js"; import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js"; @@ -89,7 +89,7 @@ function reportDuplicates( export const moduleManifestSchema = z .object({ - manifestVersion: z.literal(2), + manifestVersion: z.literal(3), id: moduleIdSchema, name: z.string().trim().min(1).max(120), description: z.string().trim().min(1).max(500).optional(), @@ -102,7 +102,7 @@ export const moduleManifestSchema = z .strict(), dependencies: dependenciesSchema, capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []), - permissions: z.array(permissionIdSchema).default(() => []), + permissions: z.array(permissionDeclarationSchema).default(() => []), storage: moduleStorageSchema, entrypoints: entrypointsSchema, }) @@ -114,7 +114,12 @@ export const moduleManifestSchema = z context, "id", ); - reportDuplicates(manifest.permissions, ["permissions"], context); + reportDuplicates( + manifest.permissions.map((permission) => permission.id), + ["permissions"], + context, + "id", + ); reportDuplicates( manifest.storage.buckets.map((bucket) => bucket.id), ["storage", "buckets"], @@ -133,11 +138,11 @@ export const moduleManifestSchema = z }); manifest.permissions.forEach((permission, index) => { - if (!permission.startsWith(`${manifest.id}.`)) { + if (!permission.id.startsWith(`${manifest.id}.`)) { context.addIssue({ code: "custom", message: `Permission identifiers owned by this module must begin with ${manifest.id}.`, - path: ["permissions", index], + path: ["permissions", index, "id"], }); } }); @@ -197,3 +202,7 @@ export type ModuleManifestInput = z.input; export type ModuleManifest = z.output; export type ModuleBucketDeclaration = z.output; export type ModuleCapabilityDeclaration = z.output; +/** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's + * authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for + * discoverability alongside the manifest's other per-field declaration types. */ +export type ModulePermissionDeclaration = PermissionDeclaration; diff --git a/packages/contracts/tests/authorization.test.ts b/packages/contracts/tests/authorization.test.ts index cd605e8..65a5a16 100644 --- a/packages/contracts/tests/authorization.test.ts +++ b/packages/contracts/tests/authorization.test.ts @@ -10,10 +10,13 @@ import { isUserActor, parseAuthorizationScope, parseGrantId, + parsePermissionDeclaration, parsePermissionId, + type PermissionDeclaration, type PermissionId, safeParseAuthorizationScope, safeParseGrantId, + safeParsePermissionDeclaration, safeParsePermissionId, SYSTEM_ACTOR, } from "../src/index.js"; @@ -50,6 +53,87 @@ describe("PermissionId", () => { }); }); +describe("PermissionDeclaration", () => { + function validDeclaration(): Record { + return { + id: "calendar.events.read", + baseline: "restricted", + description: "Read calendar events.", + }; + } + + it.each([ + "public", + "authenticated", + "restricted", + ] as const)("parses a well-formed declaration with baseline %s", (baseline) => { + const declaration = parsePermissionDeclaration({ ...validDeclaration(), baseline }); + expect(declaration).toEqual({ + id: "calendar.events.read", + baseline, + description: "Read calendar events.", + }); + }); + + it("is exactly three fields — this is the one shape both a module manifest and Core's registry parse into", () => { + const declaration: PermissionDeclaration = parsePermissionDeclaration(validDeclaration()); + expect(Object.keys(declaration).sort()).toEqual(["baseline", "description", "id"]); + }); + + it("rejects an unknown declaration property", () => { + const result = safeParsePermissionDeclaration({ + ...validDeclaration(), + scope: "calendar-event:123", + }); + expect(result.success).toBe(false); + }); + + it("rejects a missing baseline", () => { + const { baseline: _baseline, ...withoutBaseline } = validDeclaration(); + expect(safeParsePermissionDeclaration(withoutBaseline).success).toBe(false); + }); + + it("rejects an invalid baseline", () => { + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), baseline: "superuser" }).success, + ).toBe(false); + }); + + it("rejects a missing, empty, or whitespace-only description", () => { + const { description: _description, ...withoutDescription } = validDeclaration(); + expect(safeParsePermissionDeclaration(withoutDescription).success).toBe(false); + expect(safeParsePermissionDeclaration({ ...validDeclaration(), description: "" }).success).toBe( + false, + ); + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), description: " " }).success, + ).toBe(false); + }); + + it("rejects a description beyond the fixed maximum length", () => { + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(240) }) + .success, + ).toBe(true); + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(241) }) + .success, + ).toBe(false); + }); + + it("rejects a malformed permission id", () => { + expect( + safeParsePermissionDeclaration({ ...validDeclaration(), id: "not-namespaced" }).success, + ).toBe(false); + }); + + it("never registers or evaluates anything — parsing only proves the value is well-formed", () => { + // Nothing here consults a registry: `parsePermissionDeclaration` succeeding says nothing + // about whether `calendar.events.read` is actually registered anywhere. + expect(() => parsePermissionDeclaration(validDeclaration())).not.toThrow(); + }); +}); + describe("GrantId", () => { it("parses a non-empty opaque grant id and rejects empty/oversized input", () => { const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111"); diff --git a/packages/contracts/tests/module-manifest.test.ts b/packages/contracts/tests/module-manifest.test.ts index 5997339..07b5fb9 100644 --- a/packages/contracts/tests/module-manifest.test.ts +++ b/packages/contracts/tests/module-manifest.test.ts @@ -4,12 +4,13 @@ import { MODULE_LIFECYCLE_STAGES, packageRelativePathSchema, parseModuleManifest, + parsePermissionId, safeParseModuleManifest, } from "../src/index.js"; function validManifest(): Record { return { - manifestVersion: 2, + manifestVersion: 3, id: "calendar", name: "Calendar", version: "0.1.0", @@ -18,7 +19,13 @@ function validManifest(): Record { moduleSdk: "^0.1.0", }, capabilities: [{ id: "calendar.events", version: "2.3.0" }], - permissions: ["calendar.events.read"], + permissions: [ + { + id: "calendar.events.read", + baseline: "restricted", + description: "Read calendar events.", + }, + ], storage: { buckets: [{ id: "assets", description: "Calendar-owned event assets." }], }, @@ -45,16 +52,23 @@ describe("module manifest contract", () => { it("creates isolated default collections for every parsed manifest", () => { const input = validManifest(); delete input.storage; + delete input.permissions; const first = parseModuleManifest(input); const second = parseModuleManifest(input); first.dependencies.modules.required.people = "^0.1.0"; first.dependencies.capabilities.required["people.records"] = "^1.0.0"; first.storage.buckets.push({ id: "exports" }); + first.permissions.push({ + id: parsePermissionId("calendar.events.write"), + baseline: "restricted", + description: "Write calendar events.", + }); expect(second.dependencies.modules.required).toEqual({}); expect(second.dependencies.capabilities.required).toEqual({}); expect(second.storage.buckets).toEqual([]); + expect(second.permissions).toEqual([]); }); it("rejects unknown and presentation-oriented manifest properties", () => { @@ -68,14 +82,16 @@ describe("module manifest contract", () => { const manifest = { ...validManifest(), capabilities: [{ id: "media.items", version: "1.0.0" }], - permissions: ["media.items.read"], + permissions: [ + { id: "media.items.read", baseline: "restricted", description: "Read media items." }, + ], }; const result = safeParseModuleManifest(manifest); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual( - expect.arrayContaining(["capabilities.0.id", "permissions.0"]), + expect.arrayContaining(["capabilities.0.id", "permissions.0.id"]), ); } }); @@ -107,6 +123,32 @@ describe("module manifest contract", () => { } }); + it("rejects a duplicate permission id at its exact issue path", () => { + const manifest = { + ...validManifest(), + permissions: [ + { + id: "calendar.events.read", + baseline: "restricted", + description: "Read calendar events.", + }, + { + id: "calendar.events.read", + baseline: "authenticated", + description: "Read calendar events, again.", + }, + ], + }; + const result = safeParseModuleManifest(manifest); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.map((issue) => issue.path.join("."))).toContain( + "permissions.1.id", + ); + } + }); + it("rejects module and capability self-dependencies", () => { const manifest = { ...validManifest(), @@ -151,6 +193,113 @@ describe("module manifest contract", () => { expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false); }); + it("rejects a version 2 manifest rather than silently interpreting it as version 3", () => { + // manifestVersion 2's own permissions shape (a bare string array) is also what version 3 + // must reject — this manifest is invalid twice over, deliberately, since a real version 2 + // manifest is exactly this shape. + const version2Manifest = { + ...validManifest(), + manifestVersion: 2, + permissions: ["calendar.events.read"], + }; + + expect(safeParseModuleManifest(version2Manifest).success).toBe(false); + }); + + it("rejects a bare permission-id string array instead of complete declarations", () => { + const manifest = { ...validManifest(), permissions: ["calendar.events.read"] }; + + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("defaults to an empty permission list when omitted", () => { + const input = validManifest(); + delete input.permissions; + + const manifest = parseModuleManifest(input); + expect(manifest.permissions).toEqual([]); + }); + + it.each([ + "public", + "authenticated", + "restricted", + ] as const)("accepts a %s permission declaration and preserves its exact baseline", (baseline) => { + const manifest = parseModuleManifest({ + ...validManifest(), + permissions: [{ id: "calendar.events.read", baseline, description: "Read events." }], + }); + expect(manifest.permissions).toEqual([ + { id: "calendar.events.read", baseline, description: "Read events." }, + ]); + }); + + it("rejects a permission declaration with a missing baseline", () => { + const manifest = { + ...validManifest(), + permissions: [{ id: "calendar.events.read", description: "Read events." }], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("rejects a permission declaration with an invalid baseline", () => { + const manifest = { + ...validManifest(), + permissions: [ + { id: "calendar.events.read", baseline: "superuser", description: "Read events." }, + ], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("rejects a permission declaration with a missing or blank description", () => { + const missingDescription = { + ...validManifest(), + permissions: [{ id: "calendar.events.read", baseline: "restricted" }], + }; + const blankDescription = { + ...validManifest(), + permissions: [{ id: "calendar.events.read", baseline: "restricted", description: " " }], + }; + expect(safeParseModuleManifest(missingDescription).success).toBe(false); + expect(safeParseModuleManifest(blankDescription).success).toBe(false); + }); + + it("rejects a permission declaration with an oversized description", () => { + const manifest = { + ...validManifest(), + permissions: [ + { id: "calendar.events.read", baseline: "restricted", description: "x".repeat(241) }, + ], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("rejects a permission declaration with an unknown property", () => { + const manifest = { + ...validManifest(), + permissions: [ + { + id: "calendar.events.read", + baseline: "restricted", + description: "Read events.", + scope: "calendar-event:123", + }, + ], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + + it("rejects a malformed permission id", () => { + const manifest = { + ...validManifest(), + permissions: [ + { id: "Calendar.Events.Read", baseline: "restricted", description: "Read events." }, + ], + }; + expect(safeParseModuleManifest(manifest).success).toBe(false); + }); + it.each([ "../outside.ts", "/absolute.ts", diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index ccfe877..6ea316a 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 2, + "manifestVersion": 3, "id": "example", "name": "Example Module", "description": "A minimal backend-only Ekklesia module template.", @@ -14,7 +14,13 @@ "version": "1.0.0" } ], - "permissions": ["example.records.read"], + "permissions": [ + { + "id": "example.records.read", + "baseline": "restricted", + "description": "Read example records." + } + ], "storage": { "buckets": [ { diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 95ca89d..0b3b1e3 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -20,6 +20,13 @@ describe("module template", () => { expect(manifest.id).toBe("example"); expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]); + expect(manifest.permissions).toEqual([ + { + id: "example.records.read", + baseline: "restricted", + description: "Read example records.", + }, + ]); expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]); expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js"); expect(manifest.entrypoints.migrations).toBe("./drizzle"); -- 2.54.0 From edd7148aa84627484cd1c93509a9c2c8a7a847f6 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 24 Jul 2026 14:24:50 -0500 Subject: [PATCH 2/4] feat: integrate module authorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires composePlatformPermissionRegistry into apps/api's production startup: startApi() discovers every installed module, composes the one immutable platform permission registry (Core's own declarations plus every module's), and only then constructs the shared PostgreSQL pool, Better Auth, and the HTTP server. A discovery or composition failure — an invalid manifest, a reserved core.* namespace claim, or a duplicate declaration — propagates before anything exists to close; there is no Core-only registry fallback. apps/api/src/platform-permissions.ts resolves the repository root from its own file location, never process.cwd(), so package-scoped test runs discover the right modules/ directory. createApiApp/createGrantsRoutes now require the composed registry as an injected authorization option, replacing the previous hard-coded CORE_PERMISSION_REGISTRY; requirePermission continues to work identically for Core-owned and module-owned permissions, since it has no concept of the distinction. Extends pnpm doctor with a modules.permissions check (reusing the already-discovered module list, distinguishing reserved-namespace from generic-duplicate failures, never re-discovering modules) and pnpm modules:validate to compose the same registry before a module is considered installable. Adds focused coverage: startup-ordering and failure-isolation tests in apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement tests in apps/api/tests/authorization-middleware.test.ts, Doctor composition tests in core/tests/doctor.test.ts, and a live PostgreSQL end-to-end scenario proving a real installed module's permission is discoverable, grantable, and deniable once the module is absent again after a restart, without ever executing its server entrypoint. --- README.md | 24 +- apps/api/src/app.ts | 14 +- apps/api/src/platform-permissions.ts | 104 +++++++ apps/api/src/server.ts | 40 ++- apps/api/tests/api-http.integration.ts | 266 +++++++++++++++++- apps/api/tests/app.test.ts | 41 ++- .../tests/authorization-middleware.test.ts | 147 ++++++++++ apps/api/tests/lifecycle.test.ts | 235 +++++++++++++++- core/src/cli/validate-modules.ts | 13 + core/src/diagnostics/doctor-report.ts | 2 +- core/src/diagnostics/doctor.ts | 94 ++++++- core/tests/doctor-report.test.ts | 4 + core/tests/doctor.test.ts | 123 +++++++- docs/development/api.md | 12 + docs/development/authorization.md | 119 +++++++- docs/development/repository-workflow.md | 2 + docs/module-development/README.md | 11 + 17 files changed, 1205 insertions(+), 46 deletions(-) create mode 100644 apps/api/src/platform-permissions.ts diff --git a/README.md b/README.md index dfc62b1..f952bbc 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ surfaces. ## Current status -Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), -and Phase 4C (Core authorization) have landed. The repository includes +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 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 @@ -46,11 +47,22 @@ immutable permission registry; an explicit, single-use, concurrency-safe platfor command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped `/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin` -on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, service -accounts/API tokens, and module-permission registration remain later work. See -[Authorization](docs/development/authorization.md) and +on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, and service +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 +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). + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. @@ -89,6 +101,8 @@ Modules 0 modules installed ✓ Dependency graph valid Dependency order is empty. + ✓ Permission registry composed + 1 permission registered Data definitions ✓ Schemas validated diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8acd5fa..fce51be 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,8 +1,8 @@ import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; -import { - type AuthorizationEvaluator, - type AuthorizationStore, - CORE_PERMISSION_REGISTRY, +import type { + AuthorizationEvaluator, + AuthorizationStore, + PermissionRegistry, } from "@ekklesia/core/authorization"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import { Hono } from "hono"; @@ -12,6 +12,10 @@ import { createGrantsRoutes } from "./grants-routes.js"; export interface CreateApiAppAuthorizationOptions { readonly evaluator: AuthorizationEvaluator; + /** The complete platform permission registry (Core's own declarations plus every installed + * module's) — always the caller-composed registry `startApi` produces via + * `composeStartupPermissionRegistry`, never a Core-only fallback constructed here. */ + readonly registry: PermissionRegistry; readonly store: AuthorizationStore; } @@ -80,7 +84,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono { "/", createGrantsRoutes({ evaluator: options.authorization.evaluator, - registry: CORE_PERMISSION_REGISTRY, + registry: options.authorization.registry, runtime, store: options.authorization.store, trustedOrigins: allowedOrigins, diff --git a/apps/api/src/platform-permissions.ts b/apps/api/src/platform-permissions.ts new file mode 100644 index 0000000..155dd70 --- /dev/null +++ b/apps/api/src/platform-permissions.ts @@ -0,0 +1,104 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { PermissionRegistry } from "@ekklesia/core/authorization"; +import { + composePlatformPermissionRegistry, + discoverModules, + type DiscoveredModule, +} from "@ekklesia/core/modules"; + +export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID"; + +/** Never carries the underlying package.json path, filesystem error, module directory, manifest + * content, or any other machine detail — a broken repository layout is a startup-time + * configuration problem, not something to describe in detail to whatever eventually logs it. */ +export class PlatformPermissionConfigurationError extends Error { + readonly code: PlatformPermissionConfigurationErrorCode; + + constructor() { + super("Module discovery configuration could not be resolved."); + this.name = "PlatformPermissionConfigurationError"; + this.code = "PLATFORM_PERMISSION_CONFIGURATION_INVALID"; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +/** + * Resolves the repository root from this compiled/source file's own location, never + * `process.cwd()` — package-scoped test runs execute with `apps/api` as the working directory, + * which would silently point discovery at the wrong `modules/` directory. `apps/api/dist/*.js` + * (compiled) and `apps/api/src/*.ts` (source, under a test runner transforming TypeScript + * on the fly) sit at the identical depth from the repository root, so the same fixed number of + * `..` segments resolves correctly in both. + */ +function resolveRepositoryRoot(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +} + +interface PackageVersionFile { + readonly version?: unknown; +} + +async function readPackageVersion(packagePath: string): Promise { + let packageData: PackageVersionFile; + try { + packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersionFile; + } catch { + throw new PlatformPermissionConfigurationError(); + } + if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) { + throw new PlatformPermissionConfigurationError(); + } + return packageData.version; +} + +export interface ComposeStartupPermissionRegistryOptions { + /** Test-only escape hatch: overrides the directory module discovery scans for installed + * modules. Never set by the executable entry point — production always discovers the real + * repository's `modules/` directory. Platform and Module SDK versions are always read from the + * real repository's own `package.json` files regardless of this override. */ + 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. + */ + readonly discoveredModulesOverride?: readonly DiscoveredModule[]; +} + +/** + * Discovers every installed module's manifest and composes the one immutable platform permission + * registry (Core's own declarations plus every module's) — the production source of truth + * `apps/api` enforces every request against. Only manifests are read; no module server entrypoint + * is imported or executed, and no module lifecycle hook runs. Read the platform and Module SDK + * versions only when this function actually executes, never at import time. + */ +export async function composeStartupPermissionRegistry( + options: ComposeStartupPermissionRegistryOptions = {}, +): Promise { + if (options.discoveredModulesOverride) { + return composePlatformPermissionRegistry(options.discoveredModulesOverride); + } + + const repositoryRoot = resolveRepositoryRoot(); + const [platformVersion, moduleSdkVersion] = await Promise.all([ + readPackageVersion(path.join(repositoryRoot, "package.json")), + readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")), + ]); + + const modules = await discoverModules({ + moduleSdkVersion, + modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"), + platformVersion, + }); + + return composePlatformPermissionRegistry(modules); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index c4b2760..754df1b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -5,11 +5,13 @@ import { createAuthorizationStore, } from "@ekklesia/core/authorization"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; +import type { DiscoveredModule } from "@ekklesia/core/modules"; import { serve, type ServerType } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; +import { composeStartupPermissionRegistry } from "./platform-permissions.js"; const connectionTimeoutMs = 10_000; const operationTimeoutMs = 10_000; @@ -24,6 +26,13 @@ export interface StartApiOptions { /** Test-only escape hatch: never set by the executable entry point. See * `IdentityRateLimitOverride` for why production must leave this unset. */ readonly rateLimitOverride?: IdentityRateLimitOverride; + /** Test-only escape hatch: overrides the directory permission-composition module discovery + * scans. Never set by the executable entry point. See `composeStartupPermissionRegistry`. */ + readonly modulesDirectoryOverride?: string; + /** Test-only escape hatch: supplies an already-discovered module list directly to permission + * composition, skipping filesystem discovery entirely. Never set by the executable entry + * point. See `composeStartupPermissionRegistry`. */ + readonly discoveredModulesOverride?: readonly DiscoveredModule[]; } async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise { @@ -54,18 +63,33 @@ function bindServer(app: Hono, host: string, port: number): Promise } /** - * Explicit runtime composition: constructs the one shared PostgreSQL pool (identity and - * authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity - * runtime, the Core authorization store and evaluator, and the Hono application, then starts the - * Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry - * point and tests can stop the server and close the shared pool deterministically. Never applies - * migrations or generates schema — the database must already be migrated through - * `pnpm migrations:apply`. + * Explicit runtime composition, in a fixed order: discover every installed module's manifest and + * compose the one immutable platform permission registry from Core's own declarations plus every + * module's (`./platform-permissions.js`); only then construct the one shared PostgreSQL pool + * (identity and authorization each get a typed Drizzle view over it — see `./database.js`), the + * Core identity runtime, the Core authorization store and evaluator, and the Hono application; + * finally start the Node HTTP server. Permission composition happens before any pool, Better + * Auth instance, or HTTP server exists, so a discovery or composition failure — an invalid + * manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission + * namespace, or a duplicate permission declaration — leaves nothing to close: it propagates + * directly, and startup never falls back to a Core-only registry. Returns a handle with an + * idempotent `shutdown()` so both the executable entry point and tests can stop the server and + * close the shared pool deterministically. Never applies migrations or generates schema — the + * database must already be migrated through `pnpm migrations:apply`. */ export async function startApi( config: ApiRuntimeConfig, options: StartApiOptions = {}, ): Promise { + const permissionRegistry = await composeStartupPermissionRegistry({ + ...(options.modulesDirectoryOverride === undefined + ? {} + : { modulesDirectoryOverride: options.modulesDirectoryOverride }), + ...(options.discoveredModulesOverride === undefined + ? {} + : { discoveredModulesOverride: options.discoveredModulesOverride }), + }); + const databaseHandle = createApiDatabase(config.databaseUrl, { connectionTimeoutMs, operationTimeoutMs, @@ -91,7 +115,7 @@ export async function startApi( }); const app = createApiApp({ - authorization: { evaluator, store: authorizationStore }, + authorization: { evaluator, registry: permissionRegistry, store: authorizationStore }, runtime, trustedOrigins: config.identity.trustedOrigins, }); diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index c997385..812d963 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -8,12 +8,13 @@ import { createAuthorizationDrizzleDatabase, createAuthorizationStore, } from "@ekklesia/core/authorization"; +import { diagnoseEkklesia } from "@ekklesia/core/diagnostics"; import { applyModuleMigrations, createPostgresMigrationDatabase, discoverAllMigrations, } from "@ekklesia/core/migrations"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it, vi } from "vitest"; @@ -198,10 +199,60 @@ async function applyCoreMigrations(url: string): Promise { } } +const trackedModulesDirectories: string[] = []; + +async function createFixtureModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-")); + trackedModulesDirectories.push(directory); + return directory; +} + +/** Writes a minimal, structurally valid module fixture straight to disk — real discovery and + * composition run against it, nothing hand-built. The server entrypoint throws immediately if + * ever imported/executed, so a scenario that completes without that throw firing is direct + * evidence permission composition and Doctor never ran module lifecycle code. */ +async function createFixtureModule( + modulesDirectory: string, + id: string, + manifestOverrides: Record = {}, +): Promise { + const moduleDirectory = path.join(modulesDirectory, id); + const manifest = { + manifestVersion: 3, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + permissions: [], + entrypoints: { server: "./dist/server/index.js" }, + ...manifestOverrides, + }; + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${id}`, + type: "module", + version: "0.1.0", + }), + ), + writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + 'throw new Error("permission composition and Doctor must never execute a module server entrypoint");', + ), + ]); +} + afterAll(async () => { for (const databaseName of [...trackedDatabases]) { await dropDatabase(databaseName).catch(() => undefined); } + for (const directory of trackedModulesDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }).catch(() => undefined); + } }); const identityBaseUrl = "https://identity.example.test"; @@ -216,7 +267,10 @@ function apiOrigin(running: RunningApi): string { return `http://127.0.0.1:${address.port}`; } -async function startMigratedApi(url: string): Promise { +async function startMigratedApi( + url: string, + modulesDirectoryOverride?: string, +): Promise { const config = createApiRuntimeConfig( { databaseUrl: url, @@ -230,10 +284,15 @@ async function startMigratedApi(url: string): Promise { }, { environment: "production" }, ); - // A narrow, explicit test-only override: production composition (server.ts's `main`) never - // sets this. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would - // otherwise reject this suite's many sequential requests. - return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); + // Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets + // either. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would otherwise + // reject this suite's many sequential requests; `modulesDirectoryOverride` lets a scenario point + // permission composition at a real, disposable module fixture instead of this repository's own + // (empty) `modules/` directory. + return startApi(config, { + rateLimitOverride: { max: 1000, window: 1 }, + ...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }), + }); } function setCookieValue(response: Response): string | undefined { @@ -1147,3 +1206,198 @@ describe.sequential("live API — authorization infrastructure failure is never await running.shutdown().catch(() => undefined); }); }); + +describe.sequential("live API — installed module permission end-to-end", () => { + let database: { name: string; url: string }; + let modulesDirectory: string; + let running: RunningApi; + let origin: string; + let ownerUserId: string; + let ownerCookie: string; + let createdGrantId: string; + + const ownerEmail = "authz-module-owner@example.test"; + const password = "correct horse battery staple"; + const modulePermission = "calendar.events.read"; + const absentPermission = "scheduler.jobs.read"; + + it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => { + modulesDirectory = await createFixtureModulesDirectory(); + await createFixtureModule(modulesDirectory, "calendar", { + permissions: [ + { + baseline: "restricted", + description: "Read calendar events.", + id: modulePermission, + }, + ], + }); + + const report = await diagnoseEkklesia({ + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }, + runtimeRange: ">=0.0.0", + runtimeVersion: process.versions.node, + }); + + // Only the module-discovery and permission-composition checks are asserted here — this + // fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to + // find, which is unrelated to what this scenario proves and would otherwise make the check + // fail for a reason that has nothing to do with permission composition. + const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery"); + const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions"); + expect(moduleCheck).toMatchObject({ status: "pass" }); + expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); + expect(permissionsCheck).toMatchObject({ + details: ["2 permissions registered"], + status: "pass", + }); + }); + + it("starts a freshly migrated API that discovers the fixture module and signs up its eventual owner", async () => { + database = await createDisposableDatabase("authz_module"); + await applyCoreMigrations(database.url); + running = await startMigratedApi(database.url, modulesDirectory); + origin = apiOrigin(running); + + const ownerSignUp = await signUp(origin, ownerEmail, password); + ownerUserId = ownerSignUp.body.user.id; + const ownerSignIn = await signIn(origin, ownerEmail, password); + expect(ownerSignIn.cookiePair).toBeDefined(); + ownerCookie = ownerSignIn.cookiePair as string; + + // Existing identity HTTP behavior — sign-up, sign-in, principal resolution — is unaffected by + // module permission composition running before it. + const { body: principal } = await fetchPrincipal(origin, { cookie: ownerCookie }); + expect(principal).toEqual({ kind: "authenticated", userId: ownerUserId }); + }); + + it("lets an authorized platform owner create an explicit grant for the installed module's permission, with an atomic audit entry using its declared id", async () => { + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) }); + } finally { + await close(); + } + + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + const body = (await response.json()) as { + grant: { id: string; permission: string; sourceKind: string }; + }; + + expect(response.status).toBe(201); + expect(body.grant.permission).toBe(modulePermission); + expect(body.grant.sourceKind).toBe("explicit"); + createdGrantId = body.grant.id; + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ action: string; permission_id: string }>( + admin, + `select action, permission_id from "ekklesia_core_authorization_audit" where grant_id = $1`, + [createdGrantId], + ); + expect(rows).toEqual([{ action: "grant.create", permission_id: modulePermission }]); + } finally { + await safeEnd(admin); + } + }); + + it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => { + const admin = await openAdministrativeClient(database.url); + try { + const before = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`, + [absentPermission], + ); + expect(before[0]?.count).toBe("0"); + + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: absentPermission, subjectUserId: ownerUserId }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + + const after = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`, + [absentPermission], + ); + const audit = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`, + [absentPermission], + ); + expect(after[0]?.count).toBe("0"); + expect(audit[0]?.count).toBe("0"); + } finally { + await safeEnd(admin); + } + }); + + it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission while the historical grant remains listable", async () => { + await running.shutdown(); + const remainingConnections = await activeConnectionCount(database.name); + expect(remainingConnections).toBe(0); + + const emptyModulesDirectory = await createFixtureModulesDirectory(); + running = await startMigratedApi(database.url, emptyModulesDirectory); + origin = apiOrigin(running); + + // The owner's existing session survives the restart (sessions live in the database, not the + // process), so the same cookie proves ordinary identity behavior is unaffected here too. + const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { + headers: { cookie: ownerCookie }, + }); + const listBody = (await listResponse.json()) as { + grants: Array<{ id: string; permission: string }>; + }; + expect(listResponse.status).toBe(200); + expect(listBody.grants.some((grant) => grant.id === createdGrantId)).toBe(true); + + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ id: string }>( + admin, + `select id from "ekklesia_core_grant" where id = $1 and revoked_at is null`, + [createdGrantId], + ); + expect(rows).toHaveLength(1); + } finally { + await safeEnd(admin); + } + + await running.shutdown(); + await dropDatabase(database.name); + }); +}); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index d2d590c..a7d9e27 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -1,5 +1,10 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts"; -import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization"; +import { + type AuthorizationEvaluator, + type AuthorizationStore, + CORE_PERMISSION_REGISTRY, + type PermissionRegistry, +} from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; @@ -17,9 +22,13 @@ function fakeRuntime(overrides: Partial = {}): Ekklesia } /** A fully stubbed authorization dependency, denying everything by default — tests that need a - * specific decision or store behavior override the relevant method explicitly. */ + * specific decision or store behavior override the relevant method explicitly. The registry + * defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the + * mounted grants routes require) rather than an app-composed one — these tests exercise + * `createApiApp`'s wiring, not permission composition itself. */ function fakeAuthorization(overrides: { readonly evaluator?: Partial; + readonly registry?: PermissionRegistry; readonly store?: Partial; }): CreateApiAppAuthorizationOptions { const evaluator: AuthorizationEvaluator = { @@ -36,7 +45,7 @@ function fakeAuthorization(overrides: { revokePlatformOwner: vi.fn(), ...overrides.store, }; - return { evaluator, store }; + return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store }; } describe("Ekklesia API Hono application", () => { @@ -253,6 +262,32 @@ describe("Ekklesia API Hono application", () => { expect(evaluatorAuthorize).toHaveBeenCalledTimes(1); }); + it("uses exactly the injected registry, never an internal Core-only fallback", async () => { + // An evaluator that would allow anything, paired with a registry that does not register + // core.authorization.grants.manage at all — if createApiApp silently fell back to some + // internal registry containing it, this request would reach the evaluator and succeed + // (empty grant list, 200). It must instead fail closed on the registry lookup itself. + const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true }); + const emptyRegistry: PermissionRegistry = { + declarations: [], + get: () => undefined, + has: () => false, + }; + const app = createApiApp({ + authorization: fakeAuthorization({ + evaluator: { authorize: evaluatorAuthorize }, + registry: emptyRegistry, + }), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); + + const response = await app.request("/api/v1/grants?userId=user_123"); + + expect(response.status).toBe(403); + expect(evaluatorAuthorize).not.toHaveBeenCalled(); + }); + it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => { const app = createApiApp({ authorization: fakeAuthorization({}), diff --git a/apps/api/tests/authorization-middleware.test.ts b/apps/api/tests/authorization-middleware.test.ts index 9c42136..bb4aee2 100644 --- a/apps/api/tests/authorization-middleware.test.ts +++ b/apps/api/tests/authorization-middleware.test.ts @@ -151,6 +151,153 @@ describe("requirePermission", () => { }); }); +describe("requirePermission with module-owned declarations", () => { + // requirePermission has no concept of "Core" versus "module" — it only ever consults whatever + // registry it is given. These declarations are shaped exactly like what + // composePlatformPermissionRegistry would contribute from a real installed module (an id + // namespaced under a module, not `core.*`), proving the shared enforcement mechanism works + // identically for module-owned permissions without any module-specific code path. + const modulePublicPermission = parsePermissionId("calendar.events.list"); + const moduleAuthenticatedPermission = parsePermissionId("calendar.events.subscribe"); + const moduleRestrictedPermission = parsePermissionId("calendar.events.read"); + const moduleRegistry = createPermissionRegistry([ + { + baseline: "public", + description: "List public calendar events.", + id: modulePublicPermission, + }, + { + baseline: "authenticated", + description: "Subscribe to calendar event updates.", + id: moduleAuthenticatedPermission, + }, + { + baseline: "restricted", + description: "Read calendar events.", + id: moduleRestrictedPermission, + }, + ]); + + function buildModuleApp( + permissionId: typeof modulePublicPermission, + options: Partial = {}, + ) { + const runtime = options.runtime ?? fakeRuntime(); + const evaluator = options.evaluator ?? fakeEvaluator(); + const middlewareOptions: AuthorizationMiddlewareOptions = { + evaluator, + registry: moduleRegistry, + runtime, + }; + const app = new Hono(); + app.get("/module-protected", requirePermission(middlewareOptions, permissionId), (c) => + c.json({ ok: true }), + ); + return { app, evaluator, runtime }; + } + + it("resolves a module public permission's trusted baseline and passes it to the evaluator unchanged", async () => { + const evaluator = fakeEvaluator(); + const { app } = buildModuleApp(modulePublicPermission, { evaluator }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(200); + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ baseline: "public", permission: modulePublicPermission }), + ); + }); + + it("resolves a module authenticated permission's trusted baseline and passes it to the evaluator unchanged", async () => { + const evaluator = fakeEvaluator(); + const { app } = buildModuleApp(moduleAuthenticatedPermission, { evaluator }); + + await app.request("/module-protected"); + + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ + baseline: "authenticated", + permission: moduleAuthenticatedPermission, + }), + ); + }); + + it("resolves a module restricted permission's trusted baseline and passes it to the evaluator unchanged", async () => { + const evaluator = fakeEvaluator(); + const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator }); + + await app.request("/module-protected"); + + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ + baseline: "restricted", + permission: moduleRestrictedPermission, + }), + ); + }); + + it("allows an anonymous principal for a module permission the evaluator authorizes", async () => { + const runtime = fakeRuntime({ + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + }); + const { app } = buildModuleApp(modulePublicPermission, { runtime }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(200); + }); + + it("allows an authenticated principal for a module permission the evaluator authorizes", async () => { + const runtime = fakeRuntime({ + resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")), + }); + const { app } = buildModuleApp(moduleRestrictedPermission, { runtime }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(200); + }); + + it("denies a module permission the evaluator does not authorize", async () => { + const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) }); + const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + }); + + it("denies a module permission absent from the registry before ever resolving the principal or consulting the evaluator", async () => { + const runtime = fakeRuntime(); + const evaluator = fakeEvaluator(); + const absentModulePermission = parsePermissionId("scheduler.jobs.read"); + const { app } = buildModuleApp(absentModulePermission, { evaluator, runtime }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + expect(runtime.resolvePrincipal).not.toHaveBeenCalled(); + expect(evaluator.authorize).not.toHaveBeenCalled(); + }); + + it("never lets a client-supplied baseline influence the decision for a module permission", async () => { + const evaluator = fakeEvaluator(); + const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator }); + + // A hostile client cannot make a restricted module permission behave as public — there is no + // request field the middleware reads for a baseline override. + await app.request("/module-protected", { + headers: { "x-baseline": "public", "x-permission-baseline": "public" }, + }); + + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ baseline: "restricted" }), + ); + }); +}); + describe("getAuthorizedPrincipal", () => { it("throws when called on a route requirePermission never protected", async () => { const { app } = buildTestApp(); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index 6e5a3dd..c58cdae 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const poolConstructor = vi.hoisted(() => vi.fn()); const betterAuthMock = vi.hoisted(() => vi.fn()); @@ -11,6 +14,13 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("@hono/node-server", () => ({ serve: serveMock })); +import { parseModuleManifest } from "@ekklesia/contracts"; +import { + type DiscoveredModule, + ModuleDiscoveryError, + ModulePermissionCompositionError, +} from "@ekklesia/core/modules"; +import { PermissionRegistryError } from "@ekklesia/core/authorization"; import { createApiRuntimeConfig } from "../src/config.js"; import { installShutdownSignalHandlers, startApi } from "../src/server.js"; @@ -30,6 +40,84 @@ function validConfig() { ); } +const temporaryDirectories: string[] = []; + +async function createTemporaryModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-modules-")); + temporaryDirectories.push(directory); + return directory; +} + +/** Writes a minimal, structurally valid module fixture (package.json + ekklesia.module.json + + * server entrypoint) directly to disk — real discovery, not a hand-built double. The server + * entrypoint throws immediately if ever imported, so a test that reaches a successful assertion + * without that throw firing is direct evidence permission composition never executed it. */ +async function createFixtureModule( + modulesDirectory: string, + id: string, + manifestOverrides: Record = {}, +): Promise { + const moduleDirectory = path.join(modulesDirectory, id); + const manifest = { + manifestVersion: 3, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + permissions: [], + entrypoints: { server: "./dist/server/index.js" }, + ...manifestOverrides, + }; + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${id}`, + type: "module", + version: "0.1.0", + }), + ), + writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + 'throw new Error("permission composition must never execute a module server entrypoint");', + ), + ]); +} + +/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used + * only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is + * unreachable through real filesystem discovery (module ids are unique and every permission id is + * namespaced by its own module's id, so no two genuinely discovered modules can ever collide). */ +function fakeDiscoveredModule( + id: string, + permissions: ReadonlyArray<{ + id: string; + baseline: "public" | "authenticated" | "restricted"; + description: string; + }>, +): DiscoveredModule { + const manifest = parseModuleManifest({ + capabilities: [], + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { server: "./dist/does-not-exist/index.js" }, + id, + manifestVersion: 3, + name: `${id} module`, + permissions, + version: "0.1.0", + }); + return { + directory: `/modules/${id}`, + entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, + manifest, + manifestPath: `/modules/${id}/ekklesia.module.json`, + packageName: `@test/${id}`, + }; +} + /** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown` * to drive without a real socket. `close` and `once` are spies so tests can assert call counts; * `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */ @@ -285,3 +373,148 @@ describe("Ekklesia API shutdown lifecycle", () => { restore(); }); }); + +describe("Ekklesia API module permission composition at startup", () => { + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); + }); + + beforeEach(() => { + poolConstructor.mockReset(); + betterAuthMock.mockReset(); + drizzleAdapterMock.mockClear(); + serveMock.mockReset(); + }); + + it("discovers and composes permissions before constructing the database pool, Better Auth, or the HTTP server", async () => { + const modulesDirectory = await createTemporaryModulesDirectory(); + // An incompatible platform version fails discovery itself (MODULE_PLATFORM_INCOMPATIBLE), + // before permission composition — and, since composition runs before any pool exists, before + // any pool, Better Auth instance, or HTTP server could ever be constructed. + await createFixtureModule(modulesDirectory, "incompatible", { + compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" }, + }); + + await expect( + startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }), + ).rejects.toBeInstanceOf(ModuleDiscoveryError); + + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + }); + + it("fails startup when a module claims the reserved core.* permission namespace, creating no pool, Better Auth instance, or server", async () => { + const modulesDirectory = await createTemporaryModulesDirectory(); + // Only a module whose own id is literally "core" can produce a "core."-prefixed permission + // that still passes the manifest's own module-namespace check. + await createFixtureModule(modulesDirectory, "core", { + permissions: [ + { baseline: "restricted", description: "Not really Core's.", id: "core.rogue.permission" }, + ], + }); + + let captured: unknown; + try { + await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(ModulePermissionCompositionError); + expect(captured).toMatchObject({ code: "MODULE_PERMISSION_RESERVED_NAMESPACE" }); + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + }); + + it("fails startup on a duplicate permission declaration, creating no pool, Better Auth instance, or server", async () => { + const first = fakeDiscoveredModule("calendar", [ + { baseline: "restricted", description: "Read events.", id: "calendar.events.read" }, + ]); + const second = fakeDiscoveredModule("calendar", [ + { baseline: "public", description: "Also read events.", id: "calendar.events.read" }, + ]); + + let captured: unknown; + try { + await startApi(validConfig(), { discoveredModulesOverride: [first, second] }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(PermissionRegistryError); + expect(captured).toMatchObject({ code: "PERMISSION_REGISTRY_DUPLICATE_DECLARATION" }); + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + }); + + it("keeps a reserved-namespace failure and a generic duplicate-declaration failure distinguishable", async () => { + const modulesDirectory = await createTemporaryModulesDirectory(); + await createFixtureModule(modulesDirectory, "core", { + permissions: [ + { + baseline: "restricted", + description: "Attempting to shadow Core's own permission.", + id: "core.authorization.grants.manage", + }, + ], + }); + + let reservedNamespaceError: unknown; + try { + await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }); + } catch (error) { + reservedNamespaceError = error; + } + + const duplicateModule = fakeDiscoveredModule("calendar", [ + { baseline: "restricted", description: "Read events.", id: "calendar.events.read" }, + ]); + const duplicateModuleAgain = fakeDiscoveredModule("calendar", [ + { baseline: "restricted", description: "Read events, again.", id: "calendar.events.read" }, + ]); + let duplicateError: unknown; + try { + await startApi(validConfig(), { + discoveredModulesOverride: [duplicateModule, duplicateModuleAgain], + }); + } catch (error) { + duplicateError = error; + } + + // Even though the first scenario's module attempted to exactly collide with an existing + // Core-owned permission id, it is reported as a reserved-namespace violation, never as a + // generic duplicate — the two failure families never overlap. + expect(reservedNamespaceError).toBeInstanceOf(ModulePermissionCompositionError); + expect(reservedNamespaceError).not.toBeInstanceOf(PermissionRegistryError); + expect(duplicateError).toBeInstanceOf(PermissionRegistryError); + expect(duplicateError).not.toBeInstanceOf(ModulePermissionCompositionError); + }); + + it("still constructs exactly one pool and succeeds when zero modules are installed", async () => { + const modulesDirectory = await createTemporaryModulesDirectory(); + const poolEnd = vi.fn().mockResolvedValue(undefined); + poolConstructor.mockImplementation(function fakePool() { + return { end: poolEnd, on: vi.fn() }; + }); + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + mockSuccessfulServe(); + + const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }); + + expect(poolConstructor).toHaveBeenCalledTimes(1); + await running.shutdown(); + expect(poolEnd).toHaveBeenCalledTimes(1); + }); + + it("does not perform module discovery merely by importing the platform-permissions module", async () => { + const module = await import("../src/platform-permissions.js"); + + expect(typeof module.composeStartupPermissionRegistry).toBe("function"); + expect(poolConstructor).not.toHaveBeenCalled(); + }); +}); diff --git a/core/src/cli/validate-modules.ts b/core/src/cli/validate-modules.ts index 5e7b7ba..71b3f70 100644 --- a/core/src/cli/validate-modules.ts +++ b/core/src/cli/validate-modules.ts @@ -1,8 +1,11 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; +import { PermissionRegistryError } from "../authorization/index.js"; import { + composePlatformPermissionRegistry, discoverModuleSchemas, ModuleDiscoveryError, + ModulePermissionCompositionError, ModuleSchemaDiscoveryError, } from "../modules/index.js"; @@ -31,6 +34,12 @@ async function main(): Promise { }); const modules = schemaAggregate.discoveredModules; + // Reuses the modules discoverModuleSchemas already discovered — never rediscovers modules + // solely for permission composition. A manifest that passes structural validation but claims + // the reserved core.* namespace, or duplicates another declaration, fails validation here + // before this module is considered installable. + composePlatformPermissionRegistry(modules); + if (modules.length === 0) { console.log("Module validation passed (0 modules installed)."); return; @@ -57,6 +66,10 @@ main().catch((error: unknown) => { const issuePath = issue.path ? ` (${issue.path})` : ""; console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`); } + } else if (error instanceof ModulePermissionCompositionError) { + console.error(`[${error.code}] ${error.message}`); + } else if (error instanceof PermissionRegistryError) { + console.error(`[${error.code}] ${error.message}`); } else { console.error(error instanceof Error ? error.message : String(error)); } diff --git a/core/src/diagnostics/doctor-report.ts b/core/src/diagnostics/doctor-report.ts index e9a5267..bd4003c 100644 --- a/core/src/diagnostics/doctor-report.ts +++ b/core/src/diagnostics/doctor-report.ts @@ -11,7 +11,7 @@ function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSecti const sections: readonly DoctorSection[] = Object.freeze([ section("Platform", ["platform.runtime"]), - section("Modules", ["modules.discovery", "modules.dependencies"]), + section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]), section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]), section("Identity", ["identity.configuration"]), diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 53498ac..88934a5 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -1,6 +1,12 @@ import path from "node:path"; -import { isVersionCompatible, moduleIdSchema, semanticVersionSchema } from "@ekklesia/contracts"; +import { + isVersionCompatible, + moduleIdSchema, + permissionIdSchema, + semanticVersionSchema, +} from "@ekklesia/contracts"; import { getTableName } from "drizzle-orm"; +import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js"; import { type CreateIdentityConfigOptions, createIdentityConfig, @@ -18,9 +24,11 @@ import { planModuleMigrations, } from "../migrations/index.js"; import { + composePlatformPermissionRegistry, type DiscoveredModule, discoverModuleSchemas, discoverModules, + ModulePermissionCompositionError, type ModuleDiscoveryOptions, type ModuleMigrationAggregate, type ModuleSchemaAggregate, @@ -30,6 +38,7 @@ export type DoctorCheckId = | "platform.runtime" | "modules.discovery" | "modules.dependencies" + | "modules.permissions" | "schemas.discovery" | "migrations.discovery" | "postgresql.connection" @@ -68,6 +77,9 @@ export interface DoctorOptions { } export interface DoctorDependencies { + /** Synchronous, matching the real implementation: pure in-memory composition over an + * already-discovered module list, never re-discovering modules itself. */ + readonly composePermissions: (modules: readonly DiscoveredModule[]) => PermissionRegistry; readonly createMigrationDatabase: ( connectionString: string, options: PostgresMigrationDatabaseOptions, @@ -101,6 +113,7 @@ async function discoverAllMigrationsForRuntime( } const productionDependencies: DoctorDependencies = Object.freeze({ + composePermissions: composePlatformPermissionRegistry, createMigrationDatabase: createPostgresMigrationDatabase, discoverMigrations: discoverAllMigrationsForRuntime, discoverModules, @@ -163,6 +176,57 @@ function safeMigrationReference(identity: string, checksum: string): string { return `${identity} · ${checksum.slice(0, 12)}`; } +function safeModuleId(value: string): string { + if (!moduleIdSchema.safeParse(value).success) { + throw new TypeError("Unsafe module diagnostic value."); + } + return value; +} + +function safePermissionId(value: string): string { + if (!permissionIdSchema.safeParse(value).success) { + throw new TypeError("Unsafe permission diagnostic value."); + } + return value; +} + +/** Distinguishes the two composition-error families rather than describing a reserved `core.*` + * declaration as a generic duplicate. Never includes a module directory, manifest path, caught + * cause, or stack — only the module id and permission id the structured errors already carry, + * each re-validated before being interpolated (defense in depth against a future drift bug, + * matching `safeModuleDetails`/`safeMigrationReference` elsewhere in this file); if that + * re-validation itself fails, this falls back to a fixed generic detail rather than throwing. */ +function permissionCompositionFailure(error: unknown): DoctorCheck { + if (error instanceof ModulePermissionCompositionError) { + try { + const moduleId = safeModuleId(error.moduleId); + const permissionId = safePermissionId(error.permissionId); + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + `Module "${moduleId}" declared permission "${permissionId}" in the reserved core.* namespace.`, + ]); + } catch { + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + "A module declared a permission in the reserved core.* namespace.", + ]); + } + } + if (error instanceof PermissionRegistryError) { + try { + const permissionId = safePermissionId(error.permissionId); + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + `Permission "${permissionId}" is declared more than once.`, + ]); + } catch { + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + "A permission is declared more than once.", + ]); + } + } + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + "The installed permission declarations could not be composed safely.", + ]); +} + function moduleDiscoveryFailure(error: unknown): DoctorCheck { const details = error instanceof Error && error.name === "ModuleDiscoveryError" @@ -386,6 +450,33 @@ export async function diagnoseEkklesia( ]); } + // Reuses the already-discovered module list above — never re-discovers modules solely for + // permission composition. Independent of the schema/migration checks below: a permission + // composition failure here (a reserved-namespace or duplicate declaration) does not skip those, + // since neither depends on the permission registry, only on discovery having succeeded. + let permissionsCheck: DoctorCheck; + if (!discoveredModules) { + permissionsCheck = freezeCheck( + "modules.permissions", + "skipped", + "Permission composition skipped", + ["Complete module discovery is required."], + ); + } else { + try { + const registry = dependencies.composePermissions(discoveredModules); + const count = registry.declarations.length; + permissionsCheck = freezeCheck( + "modules.permissions", + "pass", + "Permission registry composed", + [`${count} ${plural(count, "permission")} registered`], + ); + } catch (error) { + permissionsCheck = permissionCompositionFailure(error); + } + } + let schemaCheck: DoctorCheck; let migrationCheck: DoctorCheck; let migrations: ModuleMigrationAggregate | undefined; @@ -466,6 +557,7 @@ export async function diagnoseEkklesia( runtimeCheck, moduleCheck, dependencyCheck, + permissionsCheck, schemaCheck, migrationCheck, postgresConnectionCheck, diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index ac7349d..7a11f49 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -6,6 +6,7 @@ import { } from "../src/diagnostics/index.js"; import type { MigrationDatabase } from "../src/migrations/index.js"; import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js"; +import { composePlatformPermissionRegistry } from "../src/modules/permissions.js"; const originalNoColor = process.env.NO_COLOR; @@ -36,6 +37,7 @@ function zeroMigrationAggregate(): ModuleMigrationAggregate { function noConfigurationDependencies(): DoctorDependencies { return { + composePermissions: composePlatformPermissionRegistry, createMigrationDatabase: () => { throw new Error("database must not be constructed"); }, @@ -75,6 +77,8 @@ Modules 0 modules installed ✓ Dependency graph valid Dependency order is empty. + ✓ Permission registry composed + 1 permission registered Data definitions ✓ Schemas validated diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 2c8f505..1a76ac5 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -1,5 +1,6 @@ -import type { ModuleManifest } from "@ekklesia/contracts"; +import { parsePermissionId, type ModuleManifest } from "@ekklesia/contracts"; import { describe, expect, it, vi } from "vitest"; +import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js"; import { type DoctorCheck, type DoctorDependencies, @@ -21,6 +22,7 @@ import type { ModuleMigrationAggregate, ModuleSchemaAggregate, } from "../src/modules/index.js"; +import { ModulePermissionCompositionError } from "../src/modules/permissions.js"; const checksumA = "a".repeat(64); const checksumB = "b".repeat(64); @@ -125,6 +127,11 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult function dependencies(overrides: Partial = {}): DoctorDependencies { return { + composePermissions: vi.fn((modules: readonly DiscoveredModule[]) => + createPermissionRegistry( + modules.flatMap((discovered) => [...discovered.manifest.permissions]), + ), + ), createMigrationDatabase: vi.fn(() => emptyDatabase()), discoverMigrations: vi.fn(async () => migrationAggregate()), discoverModules: vi.fn(async () => Object.freeze([])), @@ -196,6 +203,7 @@ describe("Ekklesia Doctor diagnostics", () => { "platform.runtime", "modules.discovery", "modules.dependencies", + "modules.permissions", "schemas.discovery", "migrations.discovery", "postgresql.connection", @@ -205,6 +213,10 @@ describe("Ekklesia Doctor diagnostics", () => { ]); expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]); expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]); + expect(check(report, "modules.permissions")).toMatchObject({ + details: ["0 permissions registered"], + status: "pass", + }); expect(check(report, "migrations.discovery").details).toEqual([ "1 migration · checksums valid", "people:0000_initial · aaaaaaaaaaaa", @@ -369,6 +381,7 @@ describe("Ekklesia Doctor diagnostics", () => { expect(check(report, "modules.discovery").status).toBe("failure"); for (const id of [ "modules.dependencies", + "modules.permissions", "schemas.discovery", "migrations.discovery", "postgresql.connection", @@ -385,6 +398,114 @@ describe("Ekklesia Doctor diagnostics", () => { expect(JSON.stringify(report)).not.toContain("password"); }); + it("passes permission composition with a safe declaration count when modules are installed", async () => { + const calendar = discoveredModule("calendar"); + const report = await diagnoseEkklesia( + options(), + dependencies({ + composePermissions: vi.fn(() => + createPermissionRegistry([ + { + baseline: "restricted", + description: "Read calendar events.", + id: parsePermissionId("calendar.events.read"), + }, + ]), + ), + discoverModules: vi.fn(async () => Object.freeze([calendar])), + }), + ); + + expect(check(report, "modules.permissions")).toMatchObject({ + details: ["1 permission registered"], + status: "pass", + }); + }); + + it("reuses the already-discovered module list rather than rediscovering modules for permission composition", async () => { + const calendar = discoveredModule("calendar"); + const composePermissions = vi.fn(() => createPermissionRegistry([])); + const discoverModules = vi.fn(async () => Object.freeze([calendar])); + await diagnoseEkklesia(options(), dependencies({ composePermissions, discoverModules })); + + expect(discoverModules).toHaveBeenCalledOnce(); + expect(composePermissions).toHaveBeenCalledOnce(); + expect(composePermissions).toHaveBeenCalledWith([calendar]); + }); + + it("fails permission composition when a module claims the reserved core.* namespace, distinctly from a generic duplicate", async () => { + const report = await diagnoseEkklesia( + options(), + dependencies({ + composePermissions: vi.fn(() => { + throw new ModulePermissionCompositionError("core", "core.rogue.permission"); + }), + }), + ); + + expect(check(report, "modules.permissions")).toMatchObject({ + details: [ + 'Module "core" declared permission "core.rogue.permission" in the reserved core.* namespace.', + ], + status: "failure", + }); + expect(report.result).toBe("not-ready"); + }); + + it("fails permission composition on a generic duplicate declaration, distinctly from a reserved-namespace violation", async () => { + const report = await diagnoseEkklesia( + options(), + dependencies({ + composePermissions: vi.fn(() => { + throw new PermissionRegistryError("calendar.events.read"); + }), + }), + ); + + expect(check(report, "modules.permissions")).toMatchObject({ + details: ['Permission "calendar.events.read" is declared more than once.'], + status: "failure", + }); + expect(report.result).toBe("not-ready"); + }); + + it("sanitizes an unexpected permission-composition exception to a fixed generic detail", async () => { + const hostile = "postgresql://admin:password@host.invalid/db /home/operator/.env"; + const report = await diagnoseEkklesia( + options(), + dependencies({ + composePermissions: vi.fn(() => { + throw new Error(hostile); + }), + }), + ); + + expect(check(report, "modules.permissions")).toMatchObject({ + details: ["The installed permission declarations could not be composed safely."], + status: "failure", + }); + expect(JSON.stringify(report)).not.toContain(hostile); + }); + + it("continues independent schema, migration, and PostgreSQL checks after permission composition fails", async () => { + const planMigrations = vi.fn(async () => plan()); + const report = await diagnoseEkklesia( + options("postgresql://not-rendered.invalid/ekklesia"), + dependencies({ + composePermissions: vi.fn(() => { + throw new PermissionRegistryError("calendar.events.read"); + }), + planMigrations, + }), + ); + + expect(check(report, "modules.permissions").status).toBe("failure"); + expect(check(report, "schemas.discovery").status).toBe("pass"); + expect(check(report, "migrations.discovery").status).toBe("pass"); + expect(check(report, "postgresql.connection").status).toBe("pass"); + expect(planMigrations).toHaveBeenCalledOnce(); + }); + it("continues independent migration and PostgreSQL checks after schema failure", async () => { const planMigrations = vi.fn(async () => plan()); const report = await diagnoseEkklesia( diff --git a/docs/development/api.md b/docs/development/api.md index 699d324..36b4237 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -206,6 +206,18 @@ trusted-proxy IP policy is configured, Better Auth may fall back to a single sha rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade abuse-control design. +## Module permission discovery at startup + +Before any database pool, Better Auth instance, or HTTP server exists, `startApi()` discovers +every installed module's manifest and composes the one immutable platform permission registry +(Core's own declarations plus every module's) that the rest of startup — and every subsequent +request — enforces against. A discovery or composition failure (an invalid manifest, a module +claiming the reserved `core.*` permission namespace, a duplicate permission declaration, ...) fails +startup outright, before there is anything to close; startup never falls back to a Core-only +registry. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement) +for the full flow, and [Module development](module-development/README.md) for the manifest shape +that produces these declarations. + ## Database connection limits `apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`, diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 8a1f3bd..9a66ce5 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -96,9 +96,73 @@ Composition itself performs no filesystem, environment, database, network, or mo 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. +integration layer depends on authorization). + +## API startup: discovery, composition, and enforcement + +`apps/api` builds its production permission registry from the complete installed-module inventory +every time it starts, in a fixed order, before anything else: + +1. Resolve module-discovery configuration (the repository's `modules/` directory, and the + platform/Module SDK versions from their own `package.json` files). +2. Discover installed modules (`discoverModules`). +3. Compose the platform permission registry (`composePlatformPermissionRegistry`). +4. Construct the shared PostgreSQL pool. +5. Construct Identity and Authorization. +6. Create the Hono application, injecting the composed registry. +7. Bind the HTTP server. + +`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3. +Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server +exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the +reserved `core.*` namespace, or a duplicate permission declaration — leaves nothing to close: it +propagates directly out of `startApi()`, and startup never falls back to `CORE_PERMISSION_REGISTRY` +or any Core-only registry. The repository root is resolved from this compiled/source file's own +location (two directories up from `apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at +identical depth), never `process.cwd()` — a package-scoped test run has `apps/api` as its working +directory, which would otherwise point discovery at the wrong `modules/` directory. + +The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot +installation, removal, or dynamic mutation of installed modules or their permissions while the +process is running — an installed-module change (add, remove, or edit a manifest) takes effect +only on the next API restart. `createApiApp` requires this registry as part of its injected +authorization options; `requirePermission` and the explicit-grant routes +(`GET/POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke`) consult exactly the same +registry Core's own `core.authorization.grants.manage` permission is registered in, so a +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. + +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* +permission usable through Core's existing, generic explicit-grant HTTP API and enforcement +middleware. + +## Doctor and modules:validate + +`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the +same already-discovered module list `modules.discovery`/`modules.dependencies` already produced — +it never re-discovers modules itself. A successful composition reports a pass with a safe +declaration count (for example `1 permission registered` with zero installed modules, since Core's +own declaration is always present); zero installed modules still composes successfully. If module +discovery itself failed, the check is skipped, matching the existing schema/migration checks' +pattern; if discovery succeeded but composition failed, schema and migration checks still run +normally — they are independent diagnostics that do not depend on the permission registry. A +reserved-namespace violation (`ModulePermissionCompositionError`, +`MODULE_PERMISSION_RESERVED_NAMESPACE`) and a generic duplicate declaration +(`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) are reported as distinct +failure details — a reserved `core.*` claim is never described as an ordinary duplicate, even +though (see above) a module attempting to exactly collide with a Core-owned id always surfaces as +the reserved-namespace failure specifically. Composition requires no database and never imports or +executes a module's server entrypoint. `pnpm modules:validate` composes the same registry from its +own already-discovered modules for the identical reason: a manifest that is otherwise structurally +valid but claims the `core.*` namespace, or duplicates a permission id, now fails validation before +that module is considered installable. ### Two layers, not one: shape versus registration @@ -265,7 +329,7 @@ cannot smuggle a server-derived field through a request that otherwise looks wel | Field | Required | Notes | | --- | --- | --- | | `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. | -| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. | +| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in the running API's composed platform registry (Core's own declarations plus every installed module's — see [API startup](#api-startup-discovery-composition-and-enforcement)). A malformed value is a `400`; a well-formed but unregistered one is a `403`. | | `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. | | `expiresAt` | No | An ISO date string if present. | @@ -357,13 +421,27 @@ there are or whether the failure happened during startup. its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner still blocking reuse, and the concurrency scenario described above. - `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure- - failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior. + failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior; + a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/ + `restricted` declarations identically to Core's own, that the evaluator receives the registry's + exact id/baseline, and that an absent module permission denies before principal resolution or + evaluator access — the shared mechanism, not a module-specific code path. +- `core/tests/doctor.test.ts` / `core/tests/doctor-report.test.ts` — the `modules.permissions` + check: passing count (including zero installed modules), reuse of the already-discovered module + list (no rediscovery), distinct reserved-namespace vs. generic-duplicate failure detail, an + unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing + independently after a composition failure, and the exact documented terminal report shape. - `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field and malformed-field rejection, a well-formed but unregistered target permission rejected with `403` and `store.createGrant` never called, exact server-derived-argument assertions, sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping. -- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above. +- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above, + plus module-permission composition at startup: discovery/composition run before any pool, + Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace + failure, and a generic duplicate-declaration failure each create zero pools/Better Auth + instances/servers and remain distinguishable from one another; zero installed modules still + succeeds; and import safety for `platform-permissions.ts`. - `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never consulting the grant or platform-owner ports for `public`/`authenticated`. @@ -373,11 +451,21 @@ there are or whether the failure happened during startup. account then managing grants as a non-owner, a well-formed but unregistered target permission rejected with `403` (with no grant or audit row written) even for a caller with full grant-management authority, a create/list/revoke cycle with audit-trail verification (using - `core.authorization.grants.manage` itself as the granted permission, since module permission - registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection, - a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an - infrastructure-failure scenario (database dropped out from under a live session) asserting a - `503` rather than a false `403` or `200`. + `core.authorization.grants.manage` itself as the granted permission), disallowed-field + rejection, missing/untrusted-origin rejection, a real relationship-derived grant proven + invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database + dropped out from under a live session) asserting a `503` rather than a false `403` or `200`. +- `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live + PostgreSQL) — a real temporary module fixture (structurally valid manifest, a server entrypoint + that throws if ever imported) proves the complete chain against a real API process and real + Doctor run: Doctor recognizes the installed module and passes permission composition without + executing its entrypoint; API startup discovers the fixture and makes its declared permission + enforceable; a bootstrapped platform owner creates a real explicit grant for the module's + permission with an atomic audit row recorded under its exact declared id; a well-formed + permission absent from the composed registry is rejected with no grant or audit write; and, + after a clean shutdown and restart with the module absent, the same permission can no longer be + newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity + HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout. Run the focused suites: @@ -389,7 +477,8 @@ pnpm test:migrations:integration # requires Docker ## Next phase -Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission -registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service -accounts/API tokens for non-browser automation, and relationship-derived grant creation itself -(a roster or similar module operation, not the generic grants API). +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. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 1d1e266..b1a56a8 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -101,6 +101,8 @@ Modules 0 modules installed ✓ Dependency graph valid Dependency order is empty. + ✓ Permission registry composed + 1 permission registered Data definitions ✓ Schemas validated diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 53947a7..b11449f 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -121,6 +121,17 @@ every discovered module's declarations, alongside its own, into the one platform 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. +`apps/api` composes this registry once, at startup, from every module installed at that time (see +[Authorization](../development/authorization.md#api-startup-discovery-composition-and-enforcement)); +installing, removing, or editing a module's manifest takes effect only on the next API restart, not +while it is running. Once installed and discovered, a module's declared permission is enforceable +and grantable through exactly the same mechanism as any Core-owned permission — the same +`requirePermission` middleware and the same `/api/v1/grants` explicit-grant HTTP API a platform +owner (or anyone they delegate `core.authorization.grants.manage` to) already uses. Mounting a +module's own HTTP routes, and creating relationship-derived grants automatically from a module's +own business logic, are both still later work; this only makes a declared permission *usable* +through Core's existing, generic surface. + 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 -- 2.54.0 From 9fa051be61a3e86e527c8f3591f4a8c630d31241 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 24 Jul 2026 17:49:42 -0500 Subject: [PATCH 3/4] docs: record module authorization architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ADR 0014 recording the durable architecture Phase 5A established across both passes: the shared contracts-layer PermissionDeclaration, manifest version 3 replacing version 2's bare permission strings, the reserved core.* namespace and the structural guarantee that a module-vs-Core collision always surfaces as ModulePermissionCompositionError rather than the generic PermissionRegistryError duplicate path, deterministic registry composition, startup ordering, repository-root/package-version resolution, registry injection into apps/api's enforcement and explicit-grant HTTP API, the static-snapshot/historical-record preservation guarantees, and Doctor/modules:validate integration. Indexes it in the ADR README, cross-references it from ADR 0006 (only its manifestVersion 2 / permission-string decision is superseded; the rest of ADR 0006 remains accepted, and its historical text is left untouched) and ADR 0013 (module permission registration, deferred there, is what ADR 0014 implements), and links it from the relevant development documentation. Corrects two comments discovered during a direct source audit that overstated where a module-vs-Core permission collision is actually caught (core/src/modules/permissions.ts, apps/api/src/platform-permissions.ts) — comment-only, no behavior change, already covered by existing tests. Also corrects a documentation claim in docs/development/authorization.md that incorrectly implied removing a module blocks explicit revocation of its historical grants; grants-routes.ts's revoke handler never consults the permission registry, so revocation remains unaffected. --- README.md | 30 ++- apps/api/src/platform-permissions.ts | 18 +- core/src/modules/permissions.ts | 19 +- ...apabilities-and-compiled-module-runtime.md | 9 + ...-core-authorization-and-owner-bootstrap.md | 7 + ...sion-declarations-and-platform-registry.md | 233 ++++++++++++++++++ docs/architecture/decisions/README.md | 1 + docs/development/authorization.md | 64 +++-- 8 files changed, 329 insertions(+), 52 deletions(-) create mode 100644 docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md 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. -- 2.54.0 From 510d76f7c27ef2be56c4155bdf4b3077a0bb3825 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 24 Jul 2026 17:59:48 -0500 Subject: [PATCH 4/4] docs: correct module authorization comments --- apps/api/src/authorization-middleware.ts | 8 +++++--- apps/api/tests/api-http.integration.ts | 8 +++++--- apps/api/tests/grants-routes.test.ts | 9 +++++++-- core/src/authorization/permissions.ts | 18 ++++++++++-------- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/api/src/authorization-middleware.ts b/apps/api/src/authorization-middleware.ts index 813d184..9b88124 100644 --- a/apps/api/src/authorization-middleware.ts +++ b/apps/api/src/authorization-middleware.ts @@ -17,9 +17,11 @@ function sanitizedDenial(c: Context, status: 403 | 503, code: string): Response } /** - * Builds a Hono middleware that enforces `permissionId`, resolved exclusively from Core's own - * trusted, server-side permission registry — a route never supplies its own baseline, and no - * request input can influence which baseline is checked. The principal is resolved exclusively + * Builds a Hono middleware that enforces `permissionId`, resolved exclusively from the injected, + * trusted, server-side `PermissionRegistry` — in production, the complete platform registry + * `startApi` composes from Core's own declarations plus every installed module's, never a + * Core-only fallback. A route never supplies its own baseline, and no request input can influence + * which baseline is checked. The principal is resolved exclusively * through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal` * already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or * grants. A permission id that is not registered fails closed (403) exactly like a denied diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 812d963..902ae1b 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -891,9 +891,11 @@ describe.sequential("live API — platform owner and delegated grant management" const ownerEmail = "authz-owner@example.test"; const managerEmail = "authz-manager@example.test"; const password = "correct horse battery staple"; - // Module permission registration does not exist yet, so `core.authorization.grants.manage` — - // the one permission `CORE_PERMISSION_REGISTRY` actually declares — stands in for "some - // registered permission" throughout this scenario's explicit-grant lifecycle. + // This scenario intentionally proves out the explicit-grant lifecycle (create, list, use, + // revoke) against Core's own grant-management permission rather than a module's — a module + // permission would exercise the exact same route and evaluator code, adding coverage breadth + // without coverage depth. The "live API — installed module permission end-to-end" scenario + // below is where a real installed module's declared permission is proven end-to-end instead. const testPermission = "core.authorization.grants.manage"; // Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the // live route rejects it even for a caller who otherwise has full grant-management authority. diff --git a/apps/api/tests/grants-routes.test.ts b/apps/api/tests/grants-routes.test.ts index 0b1b37c..07012a5 100644 --- a/apps/api/tests/grants-routes.test.ts +++ b/apps/api/tests/grants-routes.test.ts @@ -25,8 +25,13 @@ const subjectUserId = parseEkklesiaUserId("subject_1"); // (or, for listing, still return) an unregistered permission id rather than assuming registration. const permission = parsePermissionId("calendar.events.read"); // A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach -// `store.createGrant`/`store.revokeGrant` can do so without also depending on module permission -// registration, which does not exist yet. +// `store.createGrant`/`store.revokeGrant` can do so. This suite exercises `createGrantsRoutes` in +// isolation and intentionally injects `CORE_PERMISSION_REGISTRY` directly rather than a composed +// platform registry — the routes have no notion of "Core" versus "module" permissions, they only +// ever consult whichever registry they are given. Coverage proving a module-declared permission +// works through the exact same route code lives in +// `apps/api/tests/authorization-middleware.test.ts` (unit) and the "installed module permission +// end-to-end" scenario in `apps/api/tests/api-http.integration.ts` (live). const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE; const grantId = parseGrantId("11111111-1111-1111-1111-111111111111"); diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts index f5242c1..e81e56f 100644 --- a/core/src/authorization/permissions.ts +++ b/core/src/authorization/permissions.ts @@ -46,9 +46,10 @@ export class PermissionRegistryError extends Error { * Builds an immutable permission registry from a fixed list of declarations, rejecting any * duplicate permission id outright at composition time. Deliberately the smallest mechanism that * distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no - * network or filesystem I/O — a future module registration mechanism can merge its own - * declarations into a combined list and still call this same constructor, inheriting duplicate - * detection for free instead of reimplementing it. + * network or filesystem I/O. The module integration layer's composer + * (`composePlatformPermissionRegistry`, `core/src/modules/permissions.ts`) merges Core's own + * declarations with every discovered module's into one combined list and calls this same + * constructor, inheriting duplicate detection for free rather than reimplementing it. */ export function createPermissionRegistry( declarations: readonly PermissionDeclaration[], @@ -98,11 +99,12 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob ]); /** - * Core's own permission registry — the trusted source of truth `apps/api`'s authorization - * enforcement consults so an HTTP route can never supply its own baseline. Contains exactly the - * one Core-owned permission this slice needs; a future module registration mechanism is - * deliberately out of scope here and will compose its own declarations through - * `createPermissionRegistry` rather than through this constant. + * Core's own permission registry, containing exactly the Core-owned permissions declared above. + * Production `apps/api` does not consult this constant directly: it enforces requests against the + * composed platform registry (`composePlatformPermissionRegistry`, + * `core/src/modules/permissions.ts`), which merges these same declarations with every installed + * module's. `CORE_PERMISSION_REGISTRY` remains for Core-only callers and tests that need Core's + * permissions in isolation, without composing in any module. */ export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry( CORE_PERMISSION_DECLARATIONS, -- 2.54.0