diff --git a/README.md b/README.md index dfc62b1..f952bbc 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ surfaces. ## Current status -Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), -and Phase 4C (Core authorization) have landed. The repository includes +Phase 3 is complete; Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), and +Phase 4C (Core authorization) have landed; and Phase 5A (module authorization integration) is in +progress. The repository includes public module contracts, independently versioned capabilities, static module manifests, deterministic automatic discovery, compiled server loading, a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic @@ -46,11 +47,22 @@ immutable permission registry; an explicit, single-use, concurrency-safe platfor command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped `/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin` -on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, service -accounts/API tokens, and module-permission registration remain later work. See -[Authorization](docs/development/authorization.md) and +on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, and service +accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and [ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md). +Phase 5A (in progress) wires module-declared permissions into that same authorization system: a +version-3 module manifest declares permissions as complete `{id, baseline, description}` +declarations (never a bare string), and `apps/api` discovers every installed module and composes +one immutable platform permission registry — Core's own declarations plus every module's — before +constructing its database pool, Better Auth instance, or HTTP server. A module's declared +permission is then enforceable and grantable through the exact same `requirePermission` middleware +and `/api/v1/grants` HTTP API Core's own permissions already use, with `core.*` reserved and never +claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission +composition. Module HTTP route registration and relationship-derived grant creation remain later +work. See [Authorization](docs/development/authorization.md#module-permission-composition) and +[Module development](docs/module-development/README.md). + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. @@ -89,6 +101,8 @@ Modules 0 modules installed ✓ Dependency graph valid Dependency order is empty. + ✓ Permission registry composed + 1 permission registered Data definitions ✓ Schemas validated diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8acd5fa..fce51be 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,8 +1,8 @@ import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; -import { - type AuthorizationEvaluator, - type AuthorizationStore, - CORE_PERMISSION_REGISTRY, +import type { + AuthorizationEvaluator, + AuthorizationStore, + PermissionRegistry, } from "@ekklesia/core/authorization"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import { Hono } from "hono"; @@ -12,6 +12,10 @@ import { createGrantsRoutes } from "./grants-routes.js"; export interface CreateApiAppAuthorizationOptions { readonly evaluator: AuthorizationEvaluator; + /** The complete platform permission registry (Core's own declarations plus every installed + * module's) — always the caller-composed registry `startApi` produces via + * `composeStartupPermissionRegistry`, never a Core-only fallback constructed here. */ + readonly registry: PermissionRegistry; readonly store: AuthorizationStore; } @@ -80,7 +84,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono { "/", createGrantsRoutes({ evaluator: options.authorization.evaluator, - registry: CORE_PERMISSION_REGISTRY, + registry: options.authorization.registry, runtime, store: options.authorization.store, trustedOrigins: allowedOrigins, diff --git a/apps/api/src/platform-permissions.ts b/apps/api/src/platform-permissions.ts new file mode 100644 index 0000000..155dd70 --- /dev/null +++ b/apps/api/src/platform-permissions.ts @@ -0,0 +1,104 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { PermissionRegistry } from "@ekklesia/core/authorization"; +import { + composePlatformPermissionRegistry, + discoverModules, + type DiscoveredModule, +} from "@ekklesia/core/modules"; + +export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID"; + +/** Never carries the underlying package.json path, filesystem error, module directory, manifest + * content, or any other machine detail — a broken repository layout is a startup-time + * configuration problem, not something to describe in detail to whatever eventually logs it. */ +export class PlatformPermissionConfigurationError extends Error { + readonly code: PlatformPermissionConfigurationErrorCode; + + constructor() { + super("Module discovery configuration could not be resolved."); + this.name = "PlatformPermissionConfigurationError"; + this.code = "PLATFORM_PERMISSION_CONFIGURATION_INVALID"; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +/** + * Resolves the repository root from this compiled/source file's own location, never + * `process.cwd()` — package-scoped test runs execute with `apps/api` as the working directory, + * which would silently point discovery at the wrong `modules/` directory. `apps/api/dist/*.js` + * (compiled) and `apps/api/src/*.ts` (source, under a test runner transforming TypeScript + * on the fly) sit at the identical depth from the repository root, so the same fixed number of + * `..` segments resolves correctly in both. + */ +function resolveRepositoryRoot(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +} + +interface PackageVersionFile { + readonly version?: unknown; +} + +async function readPackageVersion(packagePath: string): Promise { + let packageData: PackageVersionFile; + try { + packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersionFile; + } catch { + throw new PlatformPermissionConfigurationError(); + } + if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) { + throw new PlatformPermissionConfigurationError(); + } + return packageData.version; +} + +export interface ComposeStartupPermissionRegistryOptions { + /** Test-only escape hatch: overrides the directory module discovery scans for installed + * modules. Never set by the executable entry point — production always discovers the real + * repository's `modules/` directory. Platform and Module SDK versions are always read from the + * real repository's own `package.json` files regardless of this override. */ + readonly modulesDirectoryOverride?: string; + /** Test-only escape hatch: supplies an already-discovered module list directly, skipping the + * filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry` + * on it. Exists because a genuine exact permission-id collision between two modules — or between + * a module and Core — is unreachable through real discovery: module ids are unique (discovery + * itself rejects a duplicate) and every permission id is namespaced by its own module's id, so + * no two really-discovered modules can ever contribute the same permission id. Proving + * composition's own duplicate-rejection behavior, and that startup has created no pool before it + * runs, requires supplying deliberately conflicting declarations directly. Never set by the + * executable entry point; mutually exclusive with `modulesDirectoryOverride` in practice, since + * only one discovery-bypass is ever needed for a given scenario. + */ + readonly discoveredModulesOverride?: readonly DiscoveredModule[]; +} + +/** + * Discovers every installed module's manifest and composes the one immutable platform permission + * registry (Core's own declarations plus every module's) — the production source of truth + * `apps/api` enforces every request against. Only manifests are read; no module server entrypoint + * is imported or executed, and no module lifecycle hook runs. Read the platform and Module SDK + * versions only when this function actually executes, never at import time. + */ +export async function composeStartupPermissionRegistry( + options: ComposeStartupPermissionRegistryOptions = {}, +): Promise { + if (options.discoveredModulesOverride) { + return composePlatformPermissionRegistry(options.discoveredModulesOverride); + } + + const repositoryRoot = resolveRepositoryRoot(); + const [platformVersion, moduleSdkVersion] = await Promise.all([ + readPackageVersion(path.join(repositoryRoot, "package.json")), + readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")), + ]); + + const modules = await discoverModules({ + moduleSdkVersion, + modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"), + platformVersion, + }); + + return composePlatformPermissionRegistry(modules); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index c4b2760..754df1b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -5,11 +5,13 @@ import { createAuthorizationStore, } from "@ekklesia/core/authorization"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; +import type { DiscoveredModule } from "@ekklesia/core/modules"; import { serve, type ServerType } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; +import { composeStartupPermissionRegistry } from "./platform-permissions.js"; const connectionTimeoutMs = 10_000; const operationTimeoutMs = 10_000; @@ -24,6 +26,13 @@ export interface StartApiOptions { /** Test-only escape hatch: never set by the executable entry point. See * `IdentityRateLimitOverride` for why production must leave this unset. */ readonly rateLimitOverride?: IdentityRateLimitOverride; + /** Test-only escape hatch: overrides the directory permission-composition module discovery + * scans. Never set by the executable entry point. See `composeStartupPermissionRegistry`. */ + readonly modulesDirectoryOverride?: string; + /** Test-only escape hatch: supplies an already-discovered module list directly to permission + * composition, skipping filesystem discovery entirely. Never set by the executable entry + * point. See `composeStartupPermissionRegistry`. */ + readonly discoveredModulesOverride?: readonly DiscoveredModule[]; } async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise { @@ -54,18 +63,33 @@ function bindServer(app: Hono, host: string, port: number): Promise } /** - * Explicit runtime composition: constructs the one shared PostgreSQL pool (identity and - * authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity - * runtime, the Core authorization store and evaluator, and the Hono application, then starts the - * Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry - * point and tests can stop the server and close the shared pool deterministically. Never applies - * migrations or generates schema — the database must already be migrated through - * `pnpm migrations:apply`. + * Explicit runtime composition, in a fixed order: discover every installed module's manifest and + * compose the one immutable platform permission registry from Core's own declarations plus every + * module's (`./platform-permissions.js`); only then construct the one shared PostgreSQL pool + * (identity and authorization each get a typed Drizzle view over it — see `./database.js`), the + * Core identity runtime, the Core authorization store and evaluator, and the Hono application; + * finally start the Node HTTP server. Permission composition happens before any pool, Better + * Auth instance, or HTTP server exists, so a discovery or composition failure — an invalid + * manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission + * namespace, or a duplicate permission declaration — leaves nothing to close: it propagates + * directly, and startup never falls back to a Core-only registry. Returns a handle with an + * idempotent `shutdown()` so both the executable entry point and tests can stop the server and + * close the shared pool deterministically. Never applies migrations or generates schema — the + * database must already be migrated through `pnpm migrations:apply`. */ export async function startApi( config: ApiRuntimeConfig, options: StartApiOptions = {}, ): Promise { + const permissionRegistry = await composeStartupPermissionRegistry({ + ...(options.modulesDirectoryOverride === undefined + ? {} + : { modulesDirectoryOverride: options.modulesDirectoryOverride }), + ...(options.discoveredModulesOverride === undefined + ? {} + : { discoveredModulesOverride: options.discoveredModulesOverride }), + }); + const databaseHandle = createApiDatabase(config.databaseUrl, { connectionTimeoutMs, operationTimeoutMs, @@ -91,7 +115,7 @@ export async function startApi( }); const app = createApiApp({ - authorization: { evaluator, store: authorizationStore }, + authorization: { evaluator, registry: permissionRegistry, store: authorizationStore }, runtime, trustedOrigins: config.identity.trustedOrigins, }); diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index c997385..812d963 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -8,12 +8,13 @@ import { createAuthorizationDrizzleDatabase, createAuthorizationStore, } from "@ekklesia/core/authorization"; +import { diagnoseEkklesia } from "@ekklesia/core/diagnostics"; import { applyModuleMigrations, createPostgresMigrationDatabase, discoverAllMigrations, } from "@ekklesia/core/migrations"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it, vi } from "vitest"; @@ -198,10 +199,60 @@ async function applyCoreMigrations(url: string): Promise { } } +const trackedModulesDirectories: string[] = []; + +async function createFixtureModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-")); + trackedModulesDirectories.push(directory); + return directory; +} + +/** Writes a minimal, structurally valid module fixture straight to disk — real discovery and + * composition run against it, nothing hand-built. The server entrypoint throws immediately if + * ever imported/executed, so a scenario that completes without that throw firing is direct + * evidence permission composition and Doctor never ran module lifecycle code. */ +async function createFixtureModule( + modulesDirectory: string, + id: string, + manifestOverrides: Record = {}, +): Promise { + const moduleDirectory = path.join(modulesDirectory, id); + const manifest = { + manifestVersion: 3, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + permissions: [], + entrypoints: { server: "./dist/server/index.js" }, + ...manifestOverrides, + }; + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${id}`, + type: "module", + version: "0.1.0", + }), + ), + writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + 'throw new Error("permission composition and Doctor must never execute a module server entrypoint");', + ), + ]); +} + afterAll(async () => { for (const databaseName of [...trackedDatabases]) { await dropDatabase(databaseName).catch(() => undefined); } + for (const directory of trackedModulesDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }).catch(() => undefined); + } }); const identityBaseUrl = "https://identity.example.test"; @@ -216,7 +267,10 @@ function apiOrigin(running: RunningApi): string { return `http://127.0.0.1:${address.port}`; } -async function startMigratedApi(url: string): Promise { +async function startMigratedApi( + url: string, + modulesDirectoryOverride?: string, +): Promise { const config = createApiRuntimeConfig( { databaseUrl: url, @@ -230,10 +284,15 @@ async function startMigratedApi(url: string): Promise { }, { environment: "production" }, ); - // A narrow, explicit test-only override: production composition (server.ts's `main`) never - // sets this. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would - // otherwise reject this suite's many sequential requests. - return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); + // Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets + // either. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would otherwise + // reject this suite's many sequential requests; `modulesDirectoryOverride` lets a scenario point + // permission composition at a real, disposable module fixture instead of this repository's own + // (empty) `modules/` directory. + return startApi(config, { + rateLimitOverride: { max: 1000, window: 1 }, + ...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }), + }); } function setCookieValue(response: Response): string | undefined { @@ -1147,3 +1206,198 @@ describe.sequential("live API — authorization infrastructure failure is never await running.shutdown().catch(() => undefined); }); }); + +describe.sequential("live API — installed module permission end-to-end", () => { + let database: { name: string; url: string }; + let modulesDirectory: string; + let running: RunningApi; + let origin: string; + let ownerUserId: string; + let ownerCookie: string; + let createdGrantId: string; + + const ownerEmail = "authz-module-owner@example.test"; + const password = "correct horse battery staple"; + const modulePermission = "calendar.events.read"; + const absentPermission = "scheduler.jobs.read"; + + it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => { + modulesDirectory = await createFixtureModulesDirectory(); + await createFixtureModule(modulesDirectory, "calendar", { + permissions: [ + { + baseline: "restricted", + description: "Read calendar events.", + id: modulePermission, + }, + ], + }); + + const report = await diagnoseEkklesia({ + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }, + runtimeRange: ">=0.0.0", + runtimeVersion: process.versions.node, + }); + + // Only the module-discovery and permission-composition checks are asserted here — this + // fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to + // find, which is unrelated to what this scenario proves and would otherwise make the check + // fail for a reason that has nothing to do with permission composition. + const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery"); + const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions"); + expect(moduleCheck).toMatchObject({ status: "pass" }); + expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); + expect(permissionsCheck).toMatchObject({ + details: ["2 permissions registered"], + status: "pass", + }); + }); + + it("starts a freshly migrated API that discovers the fixture module and signs up its eventual owner", async () => { + database = await createDisposableDatabase("authz_module"); + await applyCoreMigrations(database.url); + running = await startMigratedApi(database.url, modulesDirectory); + origin = apiOrigin(running); + + const ownerSignUp = await signUp(origin, ownerEmail, password); + ownerUserId = ownerSignUp.body.user.id; + const ownerSignIn = await signIn(origin, ownerEmail, password); + expect(ownerSignIn.cookiePair).toBeDefined(); + ownerCookie = ownerSignIn.cookiePair as string; + + // Existing identity HTTP behavior — sign-up, sign-in, principal resolution — is unaffected by + // module permission composition running before it. + const { body: principal } = await fetchPrincipal(origin, { cookie: ownerCookie }); + expect(principal).toEqual({ kind: "authenticated", userId: ownerUserId }); + }); + + it("lets an authorized platform owner create an explicit grant for the installed module's permission, with an atomic audit entry using its declared id", async () => { + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) }); + } finally { + await close(); + } + + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + const body = (await response.json()) as { + grant: { id: string; permission: string; sourceKind: string }; + }; + + expect(response.status).toBe(201); + expect(body.grant.permission).toBe(modulePermission); + expect(body.grant.sourceKind).toBe("explicit"); + createdGrantId = body.grant.id; + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ action: string; permission_id: string }>( + admin, + `select action, permission_id from "ekklesia_core_authorization_audit" where grant_id = $1`, + [createdGrantId], + ); + expect(rows).toEqual([{ action: "grant.create", permission_id: modulePermission }]); + } finally { + await safeEnd(admin); + } + }); + + it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => { + const admin = await openAdministrativeClient(database.url); + try { + const before = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`, + [absentPermission], + ); + expect(before[0]?.count).toBe("0"); + + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: absentPermission, subjectUserId: ownerUserId }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + + const after = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`, + [absentPermission], + ); + const audit = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`, + [absentPermission], + ); + expect(after[0]?.count).toBe("0"); + expect(audit[0]?.count).toBe("0"); + } finally { + await safeEnd(admin); + } + }); + + it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission while the historical grant remains listable", async () => { + await running.shutdown(); + const remainingConnections = await activeConnectionCount(database.name); + expect(remainingConnections).toBe(0); + + const emptyModulesDirectory = await createFixtureModulesDirectory(); + running = await startMigratedApi(database.url, emptyModulesDirectory); + origin = apiOrigin(running); + + // The owner's existing session survives the restart (sessions live in the database, not the + // process), so the same cookie proves ordinary identity behavior is unaffected here too. + const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { + headers: { cookie: ownerCookie }, + }); + const listBody = (await listResponse.json()) as { + grants: Array<{ id: string; permission: string }>; + }; + expect(listResponse.status).toBe(200); + expect(listBody.grants.some((grant) => grant.id === createdGrantId)).toBe(true); + + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ id: string }>( + admin, + `select id from "ekklesia_core_grant" where id = $1 and revoked_at is null`, + [createdGrantId], + ); + expect(rows).toHaveLength(1); + } finally { + await safeEnd(admin); + } + + await running.shutdown(); + await dropDatabase(database.name); + }); +}); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index d2d590c..a7d9e27 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -1,5 +1,10 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts"; -import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization"; +import { + type AuthorizationEvaluator, + type AuthorizationStore, + CORE_PERMISSION_REGISTRY, + type PermissionRegistry, +} from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; @@ -17,9 +22,13 @@ function fakeRuntime(overrides: Partial = {}): Ekklesia } /** A fully stubbed authorization dependency, denying everything by default — tests that need a - * specific decision or store behavior override the relevant method explicitly. */ + * specific decision or store behavior override the relevant method explicitly. The registry + * defaults to Core's own (containing `core.authorization.grants.manage`, exactly what the + * mounted grants routes require) rather than an app-composed one — these tests exercise + * `createApiApp`'s wiring, not permission composition itself. */ function fakeAuthorization(overrides: { readonly evaluator?: Partial; + readonly registry?: PermissionRegistry; readonly store?: Partial; }): CreateApiAppAuthorizationOptions { const evaluator: AuthorizationEvaluator = { @@ -36,7 +45,7 @@ function fakeAuthorization(overrides: { revokePlatformOwner: vi.fn(), ...overrides.store, }; - return { evaluator, store }; + return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store }; } describe("Ekklesia API Hono application", () => { @@ -253,6 +262,32 @@ describe("Ekklesia API Hono application", () => { expect(evaluatorAuthorize).toHaveBeenCalledTimes(1); }); + it("uses exactly the injected registry, never an internal Core-only fallback", async () => { + // An evaluator that would allow anything, paired with a registry that does not register + // core.authorization.grants.manage at all — if createApiApp silently fell back to some + // internal registry containing it, this request would reach the evaluator and succeed + // (empty grant list, 200). It must instead fail closed on the registry lookup itself. + const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true }); + const emptyRegistry: PermissionRegistry = { + declarations: [], + get: () => undefined, + has: () => false, + }; + const app = createApiApp({ + authorization: fakeAuthorization({ + evaluator: { authorize: evaluatorAuthorize }, + registry: emptyRegistry, + }), + runtime: fakeRuntime(), + trustedOrigins: [trustedOrigin], + }); + + const response = await app.request("/api/v1/grants?userId=user_123"); + + expect(response.status).toBe(403); + expect(evaluatorAuthorize).not.toHaveBeenCalled(); + }); + it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => { const app = createApiApp({ authorization: fakeAuthorization({}), diff --git a/apps/api/tests/authorization-middleware.test.ts b/apps/api/tests/authorization-middleware.test.ts index 9c42136..bb4aee2 100644 --- a/apps/api/tests/authorization-middleware.test.ts +++ b/apps/api/tests/authorization-middleware.test.ts @@ -151,6 +151,153 @@ describe("requirePermission", () => { }); }); +describe("requirePermission with module-owned declarations", () => { + // requirePermission has no concept of "Core" versus "module" — it only ever consults whatever + // registry it is given. These declarations are shaped exactly like what + // composePlatformPermissionRegistry would contribute from a real installed module (an id + // namespaced under a module, not `core.*`), proving the shared enforcement mechanism works + // identically for module-owned permissions without any module-specific code path. + const modulePublicPermission = parsePermissionId("calendar.events.list"); + const moduleAuthenticatedPermission = parsePermissionId("calendar.events.subscribe"); + const moduleRestrictedPermission = parsePermissionId("calendar.events.read"); + const moduleRegistry = createPermissionRegistry([ + { + baseline: "public", + description: "List public calendar events.", + id: modulePublicPermission, + }, + { + baseline: "authenticated", + description: "Subscribe to calendar event updates.", + id: moduleAuthenticatedPermission, + }, + { + baseline: "restricted", + description: "Read calendar events.", + id: moduleRestrictedPermission, + }, + ]); + + function buildModuleApp( + permissionId: typeof modulePublicPermission, + options: Partial = {}, + ) { + const runtime = options.runtime ?? fakeRuntime(); + const evaluator = options.evaluator ?? fakeEvaluator(); + const middlewareOptions: AuthorizationMiddlewareOptions = { + evaluator, + registry: moduleRegistry, + runtime, + }; + const app = new Hono(); + app.get("/module-protected", requirePermission(middlewareOptions, permissionId), (c) => + c.json({ ok: true }), + ); + return { app, evaluator, runtime }; + } + + it("resolves a module public permission's trusted baseline and passes it to the evaluator unchanged", async () => { + const evaluator = fakeEvaluator(); + const { app } = buildModuleApp(modulePublicPermission, { evaluator }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(200); + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ baseline: "public", permission: modulePublicPermission }), + ); + }); + + it("resolves a module authenticated permission's trusted baseline and passes it to the evaluator unchanged", async () => { + const evaluator = fakeEvaluator(); + const { app } = buildModuleApp(moduleAuthenticatedPermission, { evaluator }); + + await app.request("/module-protected"); + + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ + baseline: "authenticated", + permission: moduleAuthenticatedPermission, + }), + ); + }); + + it("resolves a module restricted permission's trusted baseline and passes it to the evaluator unchanged", async () => { + const evaluator = fakeEvaluator(); + const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator }); + + await app.request("/module-protected"); + + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ + baseline: "restricted", + permission: moduleRestrictedPermission, + }), + ); + }); + + it("allows an anonymous principal for a module permission the evaluator authorizes", async () => { + const runtime = fakeRuntime({ + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + }); + const { app } = buildModuleApp(modulePublicPermission, { runtime }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(200); + }); + + it("allows an authenticated principal for a module permission the evaluator authorizes", async () => { + const runtime = fakeRuntime({ + resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")), + }); + const { app } = buildModuleApp(moduleRestrictedPermission, { runtime }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(200); + }); + + it("denies a module permission the evaluator does not authorize", async () => { + const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) }); + const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + }); + + it("denies a module permission absent from the registry before ever resolving the principal or consulting the evaluator", async () => { + const runtime = fakeRuntime(); + const evaluator = fakeEvaluator(); + const absentModulePermission = parsePermissionId("scheduler.jobs.read"); + const { app } = buildModuleApp(absentModulePermission, { evaluator, runtime }); + + const response = await app.request("/module-protected"); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + expect(runtime.resolvePrincipal).not.toHaveBeenCalled(); + expect(evaluator.authorize).not.toHaveBeenCalled(); + }); + + it("never lets a client-supplied baseline influence the decision for a module permission", async () => { + const evaluator = fakeEvaluator(); + const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator }); + + // A hostile client cannot make a restricted module permission behave as public — there is no + // request field the middleware reads for a baseline override. + await app.request("/module-protected", { + headers: { "x-baseline": "public", "x-permission-baseline": "public" }, + }); + + expect(evaluator.authorize).toHaveBeenCalledWith( + expect.objectContaining({ baseline: "restricted" }), + ); + }); +}); + describe("getAuthorizedPrincipal", () => { it("throws when called on a route requirePermission never protected", async () => { const { app } = buildTestApp(); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index 6e5a3dd..c58cdae 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const poolConstructor = vi.hoisted(() => vi.fn()); const betterAuthMock = vi.hoisted(() => vi.fn()); @@ -11,6 +14,13 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("@hono/node-server", () => ({ serve: serveMock })); +import { parseModuleManifest } from "@ekklesia/contracts"; +import { + type DiscoveredModule, + ModuleDiscoveryError, + ModulePermissionCompositionError, +} from "@ekklesia/core/modules"; +import { PermissionRegistryError } from "@ekklesia/core/authorization"; import { createApiRuntimeConfig } from "../src/config.js"; import { installShutdownSignalHandlers, startApi } from "../src/server.js"; @@ -30,6 +40,84 @@ function validConfig() { ); } +const temporaryDirectories: string[] = []; + +async function createTemporaryModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-modules-")); + temporaryDirectories.push(directory); + return directory; +} + +/** Writes a minimal, structurally valid module fixture (package.json + ekklesia.module.json + + * server entrypoint) directly to disk — real discovery, not a hand-built double. The server + * entrypoint throws immediately if ever imported, so a test that reaches a successful assertion + * without that throw firing is direct evidence permission composition never executed it. */ +async function createFixtureModule( + modulesDirectory: string, + id: string, + manifestOverrides: Record = {}, +): Promise { + const moduleDirectory = path.join(modulesDirectory, id); + const manifest = { + manifestVersion: 3, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, + permissions: [], + entrypoints: { server: "./dist/server/index.js" }, + ...manifestOverrides, + }; + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${id}`, + type: "module", + version: "0.1.0", + }), + ), + writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + 'throw new Error("permission composition must never execute a module server entrypoint");', + ), + ]); +} + +/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used + * only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is + * unreachable through real filesystem discovery (module ids are unique and every permission id is + * namespaced by its own module's id, so no two genuinely discovered modules can ever collide). */ +function fakeDiscoveredModule( + id: string, + permissions: ReadonlyArray<{ + id: string; + baseline: "public" | "authenticated" | "restricted"; + description: string; + }>, +): DiscoveredModule { + const manifest = parseModuleManifest({ + capabilities: [], + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { server: "./dist/does-not-exist/index.js" }, + id, + manifestVersion: 3, + name: `${id} module`, + permissions, + version: "0.1.0", + }); + return { + directory: `/modules/${id}`, + entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` }, + manifest, + manifestPath: `/modules/${id}/ekklesia.module.json`, + packageName: `@test/${id}`, + }; +} + /** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown` * to drive without a real socket. `close` and `once` are spies so tests can assert call counts; * `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */ @@ -285,3 +373,148 @@ describe("Ekklesia API shutdown lifecycle", () => { restore(); }); }); + +describe("Ekklesia API module permission composition at startup", () => { + afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); + }); + + beforeEach(() => { + poolConstructor.mockReset(); + betterAuthMock.mockReset(); + drizzleAdapterMock.mockClear(); + serveMock.mockReset(); + }); + + it("discovers and composes permissions before constructing the database pool, Better Auth, or the HTTP server", async () => { + const modulesDirectory = await createTemporaryModulesDirectory(); + // An incompatible platform version fails discovery itself (MODULE_PLATFORM_INCOMPATIBLE), + // before permission composition — and, since composition runs before any pool exists, before + // any pool, Better Auth instance, or HTTP server could ever be constructed. + await createFixtureModule(modulesDirectory, "incompatible", { + compatibility: { moduleSdk: "^0.1.0", platform: ">=99.0.0" }, + }); + + await expect( + startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }), + ).rejects.toBeInstanceOf(ModuleDiscoveryError); + + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + }); + + it("fails startup when a module claims the reserved core.* permission namespace, creating no pool, Better Auth instance, or server", async () => { + const modulesDirectory = await createTemporaryModulesDirectory(); + // Only a module whose own id is literally "core" can produce a "core."-prefixed permission + // that still passes the manifest's own module-namespace check. + await createFixtureModule(modulesDirectory, "core", { + permissions: [ + { baseline: "restricted", description: "Not really Core's.", id: "core.rogue.permission" }, + ], + }); + + let captured: unknown; + try { + await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(ModulePermissionCompositionError); + expect(captured).toMatchObject({ code: "MODULE_PERMISSION_RESERVED_NAMESPACE" }); + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + }); + + it("fails startup on a duplicate permission declaration, creating no pool, Better Auth instance, or server", async () => { + const first = fakeDiscoveredModule("calendar", [ + { baseline: "restricted", description: "Read events.", id: "calendar.events.read" }, + ]); + const second = fakeDiscoveredModule("calendar", [ + { baseline: "public", description: "Also read events.", id: "calendar.events.read" }, + ]); + + let captured: unknown; + try { + await startApi(validConfig(), { discoveredModulesOverride: [first, second] }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(PermissionRegistryError); + expect(captured).toMatchObject({ code: "PERMISSION_REGISTRY_DUPLICATE_DECLARATION" }); + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + }); + + it("keeps a reserved-namespace failure and a generic duplicate-declaration failure distinguishable", async () => { + const modulesDirectory = await createTemporaryModulesDirectory(); + await createFixtureModule(modulesDirectory, "core", { + permissions: [ + { + baseline: "restricted", + description: "Attempting to shadow Core's own permission.", + id: "core.authorization.grants.manage", + }, + ], + }); + + let reservedNamespaceError: unknown; + try { + await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }); + } catch (error) { + reservedNamespaceError = error; + } + + const duplicateModule = fakeDiscoveredModule("calendar", [ + { baseline: "restricted", description: "Read events.", id: "calendar.events.read" }, + ]); + const duplicateModuleAgain = fakeDiscoveredModule("calendar", [ + { baseline: "restricted", description: "Read events, again.", id: "calendar.events.read" }, + ]); + let duplicateError: unknown; + try { + await startApi(validConfig(), { + discoveredModulesOverride: [duplicateModule, duplicateModuleAgain], + }); + } catch (error) { + duplicateError = error; + } + + // Even though the first scenario's module attempted to exactly collide with an existing + // Core-owned permission id, it is reported as a reserved-namespace violation, never as a + // generic duplicate — the two failure families never overlap. + expect(reservedNamespaceError).toBeInstanceOf(ModulePermissionCompositionError); + expect(reservedNamespaceError).not.toBeInstanceOf(PermissionRegistryError); + expect(duplicateError).toBeInstanceOf(PermissionRegistryError); + expect(duplicateError).not.toBeInstanceOf(ModulePermissionCompositionError); + }); + + it("still constructs exactly one pool and succeeds when zero modules are installed", async () => { + const modulesDirectory = await createTemporaryModulesDirectory(); + const poolEnd = vi.fn().mockResolvedValue(undefined); + poolConstructor.mockImplementation(function fakePool() { + return { end: poolEnd, on: vi.fn() }; + }); + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + mockSuccessfulServe(); + + const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }); + + expect(poolConstructor).toHaveBeenCalledTimes(1); + await running.shutdown(); + expect(poolEnd).toHaveBeenCalledTimes(1); + }); + + it("does not perform module discovery merely by importing the platform-permissions module", async () => { + const module = await import("../src/platform-permissions.js"); + + expect(typeof module.composeStartupPermissionRegistry).toBe("function"); + expect(poolConstructor).not.toHaveBeenCalled(); + }); +}); diff --git a/core/src/cli/validate-modules.ts b/core/src/cli/validate-modules.ts index 5e7b7ba..71b3f70 100644 --- a/core/src/cli/validate-modules.ts +++ b/core/src/cli/validate-modules.ts @@ -1,8 +1,11 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; +import { PermissionRegistryError } from "../authorization/index.js"; import { + composePlatformPermissionRegistry, discoverModuleSchemas, ModuleDiscoveryError, + ModulePermissionCompositionError, ModuleSchemaDiscoveryError, } from "../modules/index.js"; @@ -31,6 +34,12 @@ async function main(): Promise { }); const modules = schemaAggregate.discoveredModules; + // Reuses the modules discoverModuleSchemas already discovered — never rediscovers modules + // solely for permission composition. A manifest that passes structural validation but claims + // the reserved core.* namespace, or duplicates another declaration, fails validation here + // before this module is considered installable. + composePlatformPermissionRegistry(modules); + if (modules.length === 0) { console.log("Module validation passed (0 modules installed)."); return; @@ -57,6 +66,10 @@ main().catch((error: unknown) => { const issuePath = issue.path ? ` (${issue.path})` : ""; console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`); } + } else if (error instanceof ModulePermissionCompositionError) { + console.error(`[${error.code}] ${error.message}`); + } else if (error instanceof PermissionRegistryError) { + console.error(`[${error.code}] ${error.message}`); } else { console.error(error instanceof Error ? error.message : String(error)); } diff --git a/core/src/diagnostics/doctor-report.ts b/core/src/diagnostics/doctor-report.ts index e9a5267..bd4003c 100644 --- a/core/src/diagnostics/doctor-report.ts +++ b/core/src/diagnostics/doctor-report.ts @@ -11,7 +11,7 @@ function section(title: string, checkIds: readonly DoctorCheckId[]): DoctorSecti const sections: readonly DoctorSection[] = Object.freeze([ section("Platform", ["platform.runtime"]), - section("Modules", ["modules.discovery", "modules.dependencies"]), + section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]), section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]), section("Identity", ["identity.configuration"]), diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 53498ac..88934a5 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -1,6 +1,12 @@ import path from "node:path"; -import { isVersionCompatible, moduleIdSchema, semanticVersionSchema } from "@ekklesia/contracts"; +import { + isVersionCompatible, + moduleIdSchema, + permissionIdSchema, + semanticVersionSchema, +} from "@ekklesia/contracts"; import { getTableName } from "drizzle-orm"; +import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js"; import { type CreateIdentityConfigOptions, createIdentityConfig, @@ -18,9 +24,11 @@ import { planModuleMigrations, } from "../migrations/index.js"; import { + composePlatformPermissionRegistry, type DiscoveredModule, discoverModuleSchemas, discoverModules, + ModulePermissionCompositionError, type ModuleDiscoveryOptions, type ModuleMigrationAggregate, type ModuleSchemaAggregate, @@ -30,6 +38,7 @@ export type DoctorCheckId = | "platform.runtime" | "modules.discovery" | "modules.dependencies" + | "modules.permissions" | "schemas.discovery" | "migrations.discovery" | "postgresql.connection" @@ -68,6 +77,9 @@ export interface DoctorOptions { } export interface DoctorDependencies { + /** Synchronous, matching the real implementation: pure in-memory composition over an + * already-discovered module list, never re-discovering modules itself. */ + readonly composePermissions: (modules: readonly DiscoveredModule[]) => PermissionRegistry; readonly createMigrationDatabase: ( connectionString: string, options: PostgresMigrationDatabaseOptions, @@ -101,6 +113,7 @@ async function discoverAllMigrationsForRuntime( } const productionDependencies: DoctorDependencies = Object.freeze({ + composePermissions: composePlatformPermissionRegistry, createMigrationDatabase: createPostgresMigrationDatabase, discoverMigrations: discoverAllMigrationsForRuntime, discoverModules, @@ -163,6 +176,57 @@ function safeMigrationReference(identity: string, checksum: string): string { return `${identity} · ${checksum.slice(0, 12)}`; } +function safeModuleId(value: string): string { + if (!moduleIdSchema.safeParse(value).success) { + throw new TypeError("Unsafe module diagnostic value."); + } + return value; +} + +function safePermissionId(value: string): string { + if (!permissionIdSchema.safeParse(value).success) { + throw new TypeError("Unsafe permission diagnostic value."); + } + return value; +} + +/** Distinguishes the two composition-error families rather than describing a reserved `core.*` + * declaration as a generic duplicate. Never includes a module directory, manifest path, caught + * cause, or stack — only the module id and permission id the structured errors already carry, + * each re-validated before being interpolated (defense in depth against a future drift bug, + * matching `safeModuleDetails`/`safeMigrationReference` elsewhere in this file); if that + * re-validation itself fails, this falls back to a fixed generic detail rather than throwing. */ +function permissionCompositionFailure(error: unknown): DoctorCheck { + if (error instanceof ModulePermissionCompositionError) { + try { + const moduleId = safeModuleId(error.moduleId); + const permissionId = safePermissionId(error.permissionId); + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + `Module "${moduleId}" declared permission "${permissionId}" in the reserved core.* namespace.`, + ]); + } catch { + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + "A module declared a permission in the reserved core.* namespace.", + ]); + } + } + if (error instanceof PermissionRegistryError) { + try { + const permissionId = safePermissionId(error.permissionId); + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + `Permission "${permissionId}" is declared more than once.`, + ]); + } catch { + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + "A permission is declared more than once.", + ]); + } + } + return freezeCheck("modules.permissions", "failure", "Permission composition failed", [ + "The installed permission declarations could not be composed safely.", + ]); +} + function moduleDiscoveryFailure(error: unknown): DoctorCheck { const details = error instanceof Error && error.name === "ModuleDiscoveryError" @@ -386,6 +450,33 @@ export async function diagnoseEkklesia( ]); } + // Reuses the already-discovered module list above — never re-discovers modules solely for + // permission composition. Independent of the schema/migration checks below: a permission + // composition failure here (a reserved-namespace or duplicate declaration) does not skip those, + // since neither depends on the permission registry, only on discovery having succeeded. + let permissionsCheck: DoctorCheck; + if (!discoveredModules) { + permissionsCheck = freezeCheck( + "modules.permissions", + "skipped", + "Permission composition skipped", + ["Complete module discovery is required."], + ); + } else { + try { + const registry = dependencies.composePermissions(discoveredModules); + const count = registry.declarations.length; + permissionsCheck = freezeCheck( + "modules.permissions", + "pass", + "Permission registry composed", + [`${count} ${plural(count, "permission")} registered`], + ); + } catch (error) { + permissionsCheck = permissionCompositionFailure(error); + } + } + let schemaCheck: DoctorCheck; let migrationCheck: DoctorCheck; let migrations: ModuleMigrationAggregate | undefined; @@ -466,6 +557,7 @@ export async function diagnoseEkklesia( runtimeCheck, moduleCheck, dependencyCheck, + permissionsCheck, schemaCheck, migrationCheck, postgresConnectionCheck, diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index ac7349d..7a11f49 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -6,6 +6,7 @@ import { } from "../src/diagnostics/index.js"; import type { MigrationDatabase } from "../src/migrations/index.js"; import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js"; +import { composePlatformPermissionRegistry } from "../src/modules/permissions.js"; const originalNoColor = process.env.NO_COLOR; @@ -36,6 +37,7 @@ function zeroMigrationAggregate(): ModuleMigrationAggregate { function noConfigurationDependencies(): DoctorDependencies { return { + composePermissions: composePlatformPermissionRegistry, createMigrationDatabase: () => { throw new Error("database must not be constructed"); }, @@ -75,6 +77,8 @@ Modules 0 modules installed ✓ Dependency graph valid Dependency order is empty. + ✓ Permission registry composed + 1 permission registered Data definitions ✓ Schemas validated diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 2c8f505..1a76ac5 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -1,5 +1,6 @@ -import type { ModuleManifest } from "@ekklesia/contracts"; +import { parsePermissionId, type ModuleManifest } from "@ekklesia/contracts"; import { describe, expect, it, vi } from "vitest"; +import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js"; import { type DoctorCheck, type DoctorDependencies, @@ -21,6 +22,7 @@ import type { ModuleMigrationAggregate, ModuleSchemaAggregate, } from "../src/modules/index.js"; +import { ModulePermissionCompositionError } from "../src/modules/permissions.js"; const checksumA = "a".repeat(64); const checksumB = "b".repeat(64); @@ -125,6 +127,11 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult function dependencies(overrides: Partial = {}): DoctorDependencies { return { + composePermissions: vi.fn((modules: readonly DiscoveredModule[]) => + createPermissionRegistry( + modules.flatMap((discovered) => [...discovered.manifest.permissions]), + ), + ), createMigrationDatabase: vi.fn(() => emptyDatabase()), discoverMigrations: vi.fn(async () => migrationAggregate()), discoverModules: vi.fn(async () => Object.freeze([])), @@ -196,6 +203,7 @@ describe("Ekklesia Doctor diagnostics", () => { "platform.runtime", "modules.discovery", "modules.dependencies", + "modules.permissions", "schemas.discovery", "migrations.discovery", "postgresql.connection", @@ -205,6 +213,10 @@ describe("Ekklesia Doctor diagnostics", () => { ]); expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]); expect(check(report, "modules.dependencies").details).toEqual(["Order: people → calendar"]); + expect(check(report, "modules.permissions")).toMatchObject({ + details: ["0 permissions registered"], + status: "pass", + }); expect(check(report, "migrations.discovery").details).toEqual([ "1 migration · checksums valid", "people:0000_initial · aaaaaaaaaaaa", @@ -369,6 +381,7 @@ describe("Ekklesia Doctor diagnostics", () => { expect(check(report, "modules.discovery").status).toBe("failure"); for (const id of [ "modules.dependencies", + "modules.permissions", "schemas.discovery", "migrations.discovery", "postgresql.connection", @@ -385,6 +398,114 @@ describe("Ekklesia Doctor diagnostics", () => { expect(JSON.stringify(report)).not.toContain("password"); }); + it("passes permission composition with a safe declaration count when modules are installed", async () => { + const calendar = discoveredModule("calendar"); + const report = await diagnoseEkklesia( + options(), + dependencies({ + composePermissions: vi.fn(() => + createPermissionRegistry([ + { + baseline: "restricted", + description: "Read calendar events.", + id: parsePermissionId("calendar.events.read"), + }, + ]), + ), + discoverModules: vi.fn(async () => Object.freeze([calendar])), + }), + ); + + expect(check(report, "modules.permissions")).toMatchObject({ + details: ["1 permission registered"], + status: "pass", + }); + }); + + it("reuses the already-discovered module list rather than rediscovering modules for permission composition", async () => { + const calendar = discoveredModule("calendar"); + const composePermissions = vi.fn(() => createPermissionRegistry([])); + const discoverModules = vi.fn(async () => Object.freeze([calendar])); + await diagnoseEkklesia(options(), dependencies({ composePermissions, discoverModules })); + + expect(discoverModules).toHaveBeenCalledOnce(); + expect(composePermissions).toHaveBeenCalledOnce(); + expect(composePermissions).toHaveBeenCalledWith([calendar]); + }); + + it("fails permission composition when a module claims the reserved core.* namespace, distinctly from a generic duplicate", async () => { + const report = await diagnoseEkklesia( + options(), + dependencies({ + composePermissions: vi.fn(() => { + throw new ModulePermissionCompositionError("core", "core.rogue.permission"); + }), + }), + ); + + expect(check(report, "modules.permissions")).toMatchObject({ + details: [ + 'Module "core" declared permission "core.rogue.permission" in the reserved core.* namespace.', + ], + status: "failure", + }); + expect(report.result).toBe("not-ready"); + }); + + it("fails permission composition on a generic duplicate declaration, distinctly from a reserved-namespace violation", async () => { + const report = await diagnoseEkklesia( + options(), + dependencies({ + composePermissions: vi.fn(() => { + throw new PermissionRegistryError("calendar.events.read"); + }), + }), + ); + + expect(check(report, "modules.permissions")).toMatchObject({ + details: ['Permission "calendar.events.read" is declared more than once.'], + status: "failure", + }); + expect(report.result).toBe("not-ready"); + }); + + it("sanitizes an unexpected permission-composition exception to a fixed generic detail", async () => { + const hostile = "postgresql://admin:password@host.invalid/db /home/operator/.env"; + const report = await diagnoseEkklesia( + options(), + dependencies({ + composePermissions: vi.fn(() => { + throw new Error(hostile); + }), + }), + ); + + expect(check(report, "modules.permissions")).toMatchObject({ + details: ["The installed permission declarations could not be composed safely."], + status: "failure", + }); + expect(JSON.stringify(report)).not.toContain(hostile); + }); + + it("continues independent schema, migration, and PostgreSQL checks after permission composition fails", async () => { + const planMigrations = vi.fn(async () => plan()); + const report = await diagnoseEkklesia( + options("postgresql://not-rendered.invalid/ekklesia"), + dependencies({ + composePermissions: vi.fn(() => { + throw new PermissionRegistryError("calendar.events.read"); + }), + planMigrations, + }), + ); + + expect(check(report, "modules.permissions").status).toBe("failure"); + expect(check(report, "schemas.discovery").status).toBe("pass"); + expect(check(report, "migrations.discovery").status).toBe("pass"); + expect(check(report, "postgresql.connection").status).toBe("pass"); + expect(planMigrations).toHaveBeenCalledOnce(); + }); + it("continues independent migration and PostgreSQL checks after schema failure", async () => { const planMigrations = vi.fn(async () => plan()); const report = await diagnoseEkklesia( diff --git a/docs/development/api.md b/docs/development/api.md index 699d324..36b4237 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -206,6 +206,18 @@ trusted-proxy IP policy is configured, Better Auth may fall back to a single sha rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade abuse-control design. +## Module permission discovery at startup + +Before any database pool, Better Auth instance, or HTTP server exists, `startApi()` discovers +every installed module's manifest and composes the one immutable platform permission registry +(Core's own declarations plus every module's) that the rest of startup — and every subsequent +request — enforces against. A discovery or composition failure (an invalid manifest, a module +claiming the reserved `core.*` permission namespace, a duplicate permission declaration, ...) fails +startup outright, before there is anything to close; startup never falls back to a Core-only +registry. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement) +for the full flow, and [Module development](module-development/README.md) for the manifest shape +that produces these declarations. + ## Database connection limits `apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`, diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 8a1f3bd..9a66ce5 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -96,9 +96,73 @@ Composition itself performs no filesystem, environment, database, network, or mo operation — it is pure, synchronous, in-memory composition over data a caller already discovered and validated (`core/src/authorization` and `core/src/modules` remain independent of each other in the other direction: authorization never imports module discovery or runtime, only the module -integration layer depends on authorization). This pass stops at producing the combined registry; -wiring it into `apps/api` in place of `CORE_PERMISSION_REGISTRY`, and any module-owned HTTP -enforcement built on it, is later work. +integration layer depends on authorization). + +## API startup: discovery, composition, and enforcement + +`apps/api` builds its production permission registry from the complete installed-module inventory +every time it starts, in a fixed order, before anything else: + +1. Resolve module-discovery configuration (the repository's `modules/` directory, and the + platform/Module SDK versions from their own `package.json` files). +2. Discover installed modules (`discoverModules`). +3. Compose the platform permission registry (`composePlatformPermissionRegistry`). +4. Construct the shared PostgreSQL pool. +5. Construct Identity and Authorization. +6. Create the Hono application, injecting the composed registry. +7. Bind the HTTP server. + +`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3. +Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server +exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the +reserved `core.*` namespace, or a duplicate permission declaration — leaves nothing to close: it +propagates directly out of `startApi()`, and startup never falls back to `CORE_PERMISSION_REGISTRY` +or any Core-only registry. The repository root is resolved from this compiled/source file's own +location (two directories up from `apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at +identical depth), never `process.cwd()` — a package-scoped test run has `apps/api` as its working +directory, which would otherwise point discovery at the wrong `modules/` directory. + +The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot +installation, removal, or dynamic mutation of installed modules or their permissions while the +process is running — an installed-module change (add, remove, or edit a manifest) takes effect +only on the next API restart. `createApiApp` requires this registry as part of its injected +authorization options; `requirePermission` and the explicit-grant routes +(`GET/POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke`) consult exactly the same +registry Core's own `core.authorization.grants.manage` permission is registered in, so a +module-owned permission and Core's own are enforced, and grantable, through the identical +mechanism — there is no module-specific enforcement path. Removing a module from the next +startup's discovered inventory makes its permission unknown to new authorization checks and new +grants (a well-formed but unregistered permission denies exactly as before); it never deletes, +revokes, or otherwise modifies a historical grant or audit row already recorded for that +permission — a previously granted, still-unrevoked explicit grant for a now-uninstalled module's +permission remains visible through `GET /api/v1/grants` and simply stops being something anyone +can newly grant, revoke, or have authorized against. + +Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived +grant creation both remain deferred, unrelated slices — this work only makes a module's *declared* +permission usable through Core's existing, generic explicit-grant HTTP API and enforcement +middleware. + +## Doctor and modules:validate + +`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the +same already-discovered module list `modules.discovery`/`modules.dependencies` already produced — +it never re-discovers modules itself. A successful composition reports a pass with a safe +declaration count (for example `1 permission registered` with zero installed modules, since Core's +own declaration is always present); zero installed modules still composes successfully. If module +discovery itself failed, the check is skipped, matching the existing schema/migration checks' +pattern; if discovery succeeded but composition failed, schema and migration checks still run +normally — they are independent diagnostics that do not depend on the permission registry. A +reserved-namespace violation (`ModulePermissionCompositionError`, +`MODULE_PERMISSION_RESERVED_NAMESPACE`) and a generic duplicate declaration +(`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) are reported as distinct +failure details — a reserved `core.*` claim is never described as an ordinary duplicate, even +though (see above) a module attempting to exactly collide with a Core-owned id always surfaces as +the reserved-namespace failure specifically. Composition requires no database and never imports or +executes a module's server entrypoint. `pnpm modules:validate` composes the same registry from its +own already-discovered modules for the identical reason: a manifest that is otherwise structurally +valid but claims the `core.*` namespace, or duplicates a permission id, now fails validation before +that module is considered installable. ### Two layers, not one: shape versus registration @@ -265,7 +329,7 @@ cannot smuggle a server-derived field through a request that otherwise looks wel | Field | Required | Notes | | --- | --- | --- | | `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. | -| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. | +| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in the running API's composed platform registry (Core's own declarations plus every installed module's — see [API startup](#api-startup-discovery-composition-and-enforcement)). A malformed value is a `400`; a well-formed but unregistered one is a `403`. | | `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. | | `expiresAt` | No | An ISO date string if present. | @@ -357,13 +421,27 @@ there are or whether the failure happened during startup. its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner still blocking reuse, and the concurrency scenario described above. - `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure- - failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior. + failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior; + a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/ + `restricted` declarations identically to Core's own, that the evaluator receives the registry's + exact id/baseline, and that an absent module permission denies before principal resolution or + evaluator access — the shared mechanism, not a module-specific code path. +- `core/tests/doctor.test.ts` / `core/tests/doctor-report.test.ts` — the `modules.permissions` + check: passing count (including zero installed modules), reuse of the already-discovered module + list (no rediscovery), distinct reserved-namespace vs. generic-duplicate failure detail, an + unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing + independently after a composition failure, and the exact documented terminal report shape. - `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field and malformed-field rejection, a well-formed but unregistered target permission rejected with `403` and `store.createGrant` never called, exact server-derived-argument assertions, sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping. -- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above. +- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above, + plus module-permission composition at startup: discovery/composition run before any pool, + Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace + failure, and a generic duplicate-declaration failure each create zero pools/Better Auth + instances/servers and remain distinguishable from one another; zero installed modules still + succeeds; and import safety for `platform-permissions.ts`. - `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never consulting the grant or platform-owner ports for `public`/`authenticated`. @@ -373,11 +451,21 @@ there are or whether the failure happened during startup. account then managing grants as a non-owner, a well-formed but unregistered target permission rejected with `403` (with no grant or audit row written) even for a caller with full grant-management authority, a create/list/revoke cycle with audit-trail verification (using - `core.authorization.grants.manage` itself as the granted permission, since module permission - registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection, - a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an - infrastructure-failure scenario (database dropped out from under a live session) asserting a - `503` rather than a false `403` or `200`. + `core.authorization.grants.manage` itself as the granted permission), disallowed-field + rejection, missing/untrusted-origin rejection, a real relationship-derived grant proven + invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database + dropped out from under a live session) asserting a `503` rather than a false `403` or `200`. +- `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live + PostgreSQL) — a real temporary module fixture (structurally valid manifest, a server entrypoint + that throws if ever imported) proves the complete chain against a real API process and real + Doctor run: Doctor recognizes the installed module and passes permission composition without + executing its entrypoint; API startup discovers the fixture and makes its declared permission + enforceable; a bootstrapped platform owner creates a real explicit grant for the module's + permission with an atomic audit row recorded under its exact declared id; a well-formed + permission absent from the composed registry is rejected with no grant or audit write; and, + after a clean shutdown and restart with the module absent, the same permission can no longer be + newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity + HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout. Run the focused suites: @@ -389,7 +477,8 @@ pnpm test:migrations:integration # requires Docker ## Next phase -Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission -registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service -accounts/API tokens for non-browser automation, and relationship-derived grant creation itself -(a roster or similar module operation, not the generic grants API). +Phase 5A, pass 3: deferred from this slice — module HTTP route-registration contracts (a module +mounting its own endpoints), a module runtime-context authorization service, relationship-derived +grant creation itself (a roster or similar module operation, not the generic grants API), +roles/groups/teams/tenants, an owner-management HTTP endpoint, and service accounts/API tokens for +non-browser automation. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 1d1e266..b1a56a8 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -101,6 +101,8 @@ Modules 0 modules installed ✓ Dependency graph valid Dependency order is empty. + ✓ Permission registry composed + 1 permission registered Data definitions ✓ Schemas validated diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 53947a7..b11449f 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -121,6 +121,17 @@ every discovered module's declarations, alongside its own, into the one platform registry). A duplicate permission id within one manifest, or a permission id outside the module's own namespace, fails validation the same way a duplicate or misnamed capability id does. +`apps/api` composes this registry once, at startup, from every module installed at that time (see +[Authorization](../development/authorization.md#api-startup-discovery-composition-and-enforcement)); +installing, removing, or editing a module's manifest takes effect only on the next API restart, not +while it is running. Once installed and discovered, a module's declared permission is enforceable +and grantable through exactly the same mechanism as any Core-owned permission — the same +`requirePermission` middleware and the same `/api/v1/grants` explicit-grant HTTP API a platform +owner (or anyone they delegate `core.authorization.grants.manage` to) already uses. Mounting a +module's own HTTP routes, and creating relationship-derived grants automatically from a module's +own business logic, are both still later work; this only makes a declared permission *usable* +through Core's existing, generic surface. + Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package, and exist. Production never imports source TypeScript directly. Modules with no tables omit