feat: integrate module authorization #11
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user