feat: integrate module authorization #11

Merged
Zayin_Admin merged 4 commits from phase-5/module-authorization-integration into main 2026-07-24 23:05:43 +00:00
17 changed files with 1205 additions and 46 deletions
Showing only changes of commit edd7148aa8 - Show all commits
+19 -5
View File
@@ -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
+9 -5
View File
@@ -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,
+104
View File
@@ -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<string> {
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<PermissionRegistry> {
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);
}
+32 -8
View File
@@ -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<void> {
@@ -54,18 +63,33 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
}
/**
* 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<RunningApi> {
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,
});
+260 -6
View File
@@ -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<void> {
}
}
const trackedModulesDirectories: string[] = [];
async function createFixtureModulesDirectory(): Promise<string> {
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<string, unknown> = {},
): Promise<void> {
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<RunningApi> {
async function startMigratedApi(
url: string,
modulesDirectoryOverride?: string,
): Promise<RunningApi> {
const config = createApiRuntimeConfig(
{
databaseUrl: url,
@@ -230,10 +284,15 @@ async function startMigratedApi(url: string): Promise<RunningApi> {
},
{ 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 = "[email protected]";
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);
});
});
+38 -3
View File
@@ -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<EkklesiaIdentityRuntime> = {}): 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<AuthorizationEvaluator>;
readonly registry?: PermissionRegistry;
readonly store?: Partial<AuthorizationStore>;
}): 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({}),
@@ -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<AuthorizationMiddlewareOptions> = {},
) {
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();
+234 -1
View File
@@ -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<string> {
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<string, unknown> = {},
): Promise<void> {
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();
});
});
+13
View File
@@ -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<void> {
});
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));
}
+1 -1
View File
@@ -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"]),
+93 -1
View File
@@ -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,
+4
View File
@@ -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
+122 -1
View File
@@ -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> = {}): 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:[email protected]/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(
+12
View File
@@ -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`,
+104 -15
View File
@@ -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.
+2
View File
@@ -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
+11
View File
@@ -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