diff --git a/README.md b/README.md index dfc62b1..fab23bb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ 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 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 @@ -46,11 +47,26 @@ 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 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. 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 Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. @@ -89,6 +105,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/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/src/platform-permissions.ts b/apps/api/src/platform-permissions.ts new file mode 100644 index 0000000..707a38e --- /dev/null +++ b/apps/api/src/platform-permissions.ts @@ -0,0 +1,106 @@ +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 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[]; +} + +/** + * 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..902ae1b 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 { @@ -832,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. @@ -1147,3 +1208,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/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/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/authorization/permissions.ts b/core/src/authorization/permissions.ts index 66e52ce..e81e56f 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[]; @@ -53,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[], @@ -89,7 +83,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.", @@ -98,11 +99,12 @@ const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.fr ]); /** - * 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, 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/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..dc82a93 --- /dev/null +++ b/core/src/modules/permissions.ts @@ -0,0 +1,82 @@ +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 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. 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[], +): 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-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 9854d2c..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); @@ -39,7 +41,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: [] }, @@ -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/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/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/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 177eda3..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 @@ -31,10 +36,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 +58,121 @@ 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). + +## 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, 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* +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 @@ -218,7 +339,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. | @@ -287,7 +408,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` @@ -298,13 +431,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`. @@ -314,11 +461,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: @@ -330,7 +487,10 @@ 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). +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. 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 6dcbddf..b11449f 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,33 @@ 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. + +`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 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");