feat: integrate module authorization
Wires composePlatformPermissionRegistry into apps/api's production startup: startApi() discovers every installed module, composes the one immutable platform permission registry (Core's own declarations plus every module's), and only then constructs the shared PostgreSQL pool, Better Auth, and the HTTP server. A discovery or composition failure — an invalid manifest, a reserved core.* namespace claim, or a duplicate declaration — propagates before anything exists to close; there is no Core-only registry fallback. apps/api/src/platform-permissions.ts resolves the repository root from its own file location, never process.cwd(), so package-scoped test runs discover the right modules/ directory. createApiApp/createGrantsRoutes now require the composed registry as an injected authorization option, replacing the previous hard-coded CORE_PERMISSION_REGISTRY; requirePermission continues to work identically for Core-owned and module-owned permissions, since it has no concept of the distinction. Extends pnpm doctor with a modules.permissions check (reusing the already-discovered module list, distinguishing reserved-namespace from generic-duplicate failures, never re-discovering modules) and pnpm modules:validate to compose the same registry before a module is considered installable. Adds focused coverage: startup-ordering and failure-isolation tests in apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement tests in apps/api/tests/authorization-middleware.test.ts, Doctor composition tests in core/tests/doctor.test.ts, and a live PostgreSQL end-to-end scenario proving a real installed module's permission is discoverable, grantable, and deniable once the module is absent again after a restart, without ever executing its server entrypoint.
This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user