feat: integrate module authorization #11
@@ -7,7 +7,8 @@ surfaces.
|
||||
## Current status
|
||||
|
||||
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
|
||||
and Phase 4C (Core authorization) have landed. The repository includes
|
||||
Phase 4C (Core authorization), and Phase 5A (module authorization integration) have landed. The
|
||||
repository includes
|
||||
public module contracts, independently versioned capabilities, static module manifests,
|
||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
||||
@@ -46,11 +47,26 @@ immutable permission registry; an explicit, single-use, concurrency-safe platfor
|
||||
command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup
|
||||
order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped
|
||||
`/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin`
|
||||
on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, service
|
||||
accounts/API tokens, and module-permission registration remain later work. See
|
||||
[Authorization](docs/development/authorization.md) and
|
||||
on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, and service
|
||||
accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and
|
||||
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
||||
|
||||
Phase 5A wires module-declared permissions into that same authorization system: a version-3 module
|
||||
manifest declares permissions as complete `{id, baseline, description}` declarations (never a bare
|
||||
string), and `apps/api` discovers every installed module and composes one immutable platform
|
||||
permission registry — Core's own declarations plus every module's — before constructing its
|
||||
database pool, Better Auth instance, or HTTP server. A module's declared permission is then
|
||||
enforceable and grantable through the exact same `requirePermission` middleware and
|
||||
`/api/v1/grants` HTTP API Core's own permissions already use, with `core.*` reserved and never
|
||||
claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission
|
||||
composition. Installing, removing, or editing a module currently requires an API restart; removing
|
||||
one makes its permission unavailable to new checks and new grants without deleting or revoking any
|
||||
historical grant or audit row. Module HTTP route registration and relationship-derived grant
|
||||
creation remain later work. See
|
||||
[Authorization](docs/development/authorization.md#module-permission-composition),
|
||||
[Module development](docs/module-development/README.md), and
|
||||
[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md).
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
@@ -89,6 +105,8 @@ Modules
|
||||
0 modules installed
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
1 permission registered
|
||||
|
||||
Data definitions
|
||||
✓ Schemas validated
|
||||
|
||||
+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,
|
||||
|
||||
@@ -17,9 +17,11 @@ function sanitizedDenial(c: Context, status: 403 | 503, code: string): Response
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Hono middleware that enforces `permissionId`, resolved exclusively from Core's own
|
||||
* trusted, server-side permission registry — a route never supplies its own baseline, and no
|
||||
* request input can influence which baseline is checked. The principal is resolved exclusively
|
||||
* Builds a Hono middleware that enforces `permissionId`, resolved exclusively from the injected,
|
||||
* trusted, server-side `PermissionRegistry` — in production, the complete platform registry
|
||||
* `startApi` composes from Core's own declarations plus every installed module's, never a
|
||||
* Core-only fallback. A route never supplies its own baseline, and no request input can influence
|
||||
* which baseline is checked. The principal is resolved exclusively
|
||||
* through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal`
|
||||
* already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or
|
||||
* grants. A permission id that is not registered fails closed (403) exactly like a denied
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { PermissionRegistry } from "@ekklesia/core/authorization";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
discoverModules,
|
||||
type DiscoveredModule,
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
||||
|
||||
/** Never carries the underlying package.json path, filesystem error, module directory, manifest
|
||||
* content, or any other machine detail — a broken repository layout is a startup-time
|
||||
* configuration problem, not something to describe in detail to whatever eventually logs it. */
|
||||
export class PlatformPermissionConfigurationError extends Error {
|
||||
readonly code: PlatformPermissionConfigurationErrorCode;
|
||||
|
||||
constructor() {
|
||||
super("Module discovery configuration could not be resolved.");
|
||||
this.name = "PlatformPermissionConfigurationError";
|
||||
this.code = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the repository root from this compiled/source file's own location, never
|
||||
* `process.cwd()` — package-scoped test runs execute with `apps/api` as the working directory,
|
||||
* which would silently point discovery at the wrong `modules/` directory. `apps/api/dist/*.js`
|
||||
* (compiled) and `apps/api/src/*.ts` (source, under a test runner transforming TypeScript
|
||||
* on the fly) sit at the identical depth from the repository root, so the same fixed number of
|
||||
* `..` segments resolves correctly in both.
|
||||
*/
|
||||
function resolveRepositoryRoot(): string {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
}
|
||||
|
||||
interface PackageVersionFile {
|
||||
readonly version?: unknown;
|
||||
}
|
||||
|
||||
async function readPackageVersion(packagePath: string): Promise<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 is
|
||||
* unreachable through real discovery: module ids are unique (discovery itself rejects a
|
||||
* duplicate) and every permission id is namespaced by its own module's id, so no two
|
||||
* really-discovered modules can ever contribute the same permission id. Proving
|
||||
* `PermissionRegistryError`'s own duplicate-rejection behavior in composition, and that startup
|
||||
* has created no pool before it runs, requires supplying deliberately conflicting declarations
|
||||
* directly. (A module-vs-Core collision, by contrast, *is* reachable through real discovery — a
|
||||
* module whose own id is `"core"` — and surfaces as `ModulePermissionCompositionError`, not this
|
||||
* case; `modulesDirectoryOverride` with a real fixture is what proves that one.) Never set by the
|
||||
* executable entry point.
|
||||
*/
|
||||
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers every installed module's manifest and composes the one immutable platform permission
|
||||
* registry (Core's own declarations plus every module's) — the production source of truth
|
||||
* `apps/api` enforces every request against. Only manifests are read; no module server entrypoint
|
||||
* is imported or executed, and no module lifecycle hook runs. Read the platform and Module SDK
|
||||
* versions only when this function actually executes, never at import time.
|
||||
*/
|
||||
export async function composeStartupPermissionRegistry(
|
||||
options: ComposeStartupPermissionRegistryOptions = {},
|
||||
): Promise<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 {
|
||||
@@ -832,9 +891,11 @@ describe.sequential("live API — platform owner and delegated grant management"
|
||||
const ownerEmail = "[email protected]";
|
||||
const managerEmail = "[email protected]";
|
||||
const password = "correct horse battery staple";
|
||||
// Module permission registration does not exist yet, so `core.authorization.grants.manage` —
|
||||
// the one permission `CORE_PERMISSION_REGISTRY` actually declares — stands in for "some
|
||||
// registered permission" throughout this scenario's explicit-grant lifecycle.
|
||||
// This scenario intentionally proves out the explicit-grant lifecycle (create, list, use,
|
||||
// revoke) against Core's own grant-management permission rather than a module's — a module
|
||||
// permission would exercise the exact same route and evaluator code, adding coverage breadth
|
||||
// without coverage depth. The "live API — installed module permission end-to-end" scenario
|
||||
// below is where a real installed module's declared permission is proven end-to-end instead.
|
||||
const testPermission = "core.authorization.grants.manage";
|
||||
// Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the
|
||||
// live route rejects it even for a caller who otherwise has full grant-management authority.
|
||||
@@ -1147,3 +1208,198 @@ describe.sequential("live API — authorization infrastructure failure is never
|
||||
await running.shutdown().catch(() => undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — installed module permission end-to-end", () => {
|
||||
let database: { name: string; url: string };
|
||||
let modulesDirectory: string;
|
||||
let running: RunningApi;
|
||||
let origin: string;
|
||||
let ownerUserId: string;
|
||||
let ownerCookie: string;
|
||||
let createdGrantId: string;
|
||||
|
||||
const ownerEmail = "[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();
|
||||
|
||||
@@ -25,8 +25,13 @@ const subjectUserId = parseEkklesiaUserId("subject_1");
|
||||
// (or, for listing, still return) an unregistered permission id rather than assuming registration.
|
||||
const permission = parsePermissionId("calendar.events.read");
|
||||
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
|
||||
// `store.createGrant`/`store.revokeGrant` can do so without also depending on module permission
|
||||
// registration, which does not exist yet.
|
||||
// `store.createGrant`/`store.revokeGrant` can do so. This suite exercises `createGrantsRoutes` in
|
||||
// isolation and intentionally injects `CORE_PERMISSION_REGISTRY` directly rather than a composed
|
||||
// platform registry — the routes have no notion of "Core" versus "module" permissions, they only
|
||||
// ever consult whichever registry they are given. Coverage proving a module-declared permission
|
||||
// works through the exact same route code lives in
|
||||
// `apps/api/tests/authorization-middleware.test.ts` (unit) and the "installed module permission
|
||||
// end-to-end" scenario in `apps/api/tests/api-http.integration.ts` (live).
|
||||
const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
|
||||
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
|
||||
@@ -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,25 +1,18 @@
|
||||
import {
|
||||
type PermissionBaseline,
|
||||
type PermissionDeclaration,
|
||||
type PermissionId,
|
||||
parsePermissionId,
|
||||
safeParsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
/**
|
||||
* A registered permission declaration: the trusted pairing of a permission identifier with its
|
||||
* one baseline. This is what distinguishes a *registered* permission from a merely well-formed
|
||||
* `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an
|
||||
* explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller
|
||||
* (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that
|
||||
* baseline is never client-supplied or guessed.
|
||||
* Core's registry consumes the one shared `PermissionDeclaration` shape
|
||||
* (`packages/contracts/src/authorization.ts`) rather than declaring its own interface — a module
|
||||
* manifest's `permissions` array parses into the exact same type, so a declaration a module ships
|
||||
* and a declaration Core registers are provably interchangeable. Re-exported here so existing
|
||||
* `@ekklesia/core/authorization` consumers keep importing it from this package.
|
||||
*/
|
||||
export interface PermissionDeclaration {
|
||||
readonly id: PermissionId;
|
||||
readonly baseline: PermissionBaseline;
|
||||
/** Human-readable, for operators and audit review only — never interpreted by the evaluator or
|
||||
* by enforcement. */
|
||||
readonly description: string;
|
||||
}
|
||||
export type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts";
|
||||
|
||||
export interface PermissionRegistry {
|
||||
readonly declarations: readonly PermissionDeclaration[];
|
||||
@@ -53,9 +46,10 @@ export class PermissionRegistryError extends Error {
|
||||
* Builds an immutable permission registry from a fixed list of declarations, rejecting any
|
||||
* duplicate permission id outright at composition time. Deliberately the smallest mechanism that
|
||||
* distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no
|
||||
* network or filesystem I/O — a future module registration mechanism can merge its own
|
||||
* declarations into a combined list and still call this same constructor, inheriting duplicate
|
||||
* detection for free instead of reimplementing it.
|
||||
* network or filesystem I/O. The module integration layer's composer
|
||||
* (`composePlatformPermissionRegistry`, `core/src/modules/permissions.ts`) merges Core's own
|
||||
* declarations with every discovered module's into one combined list and calls this same
|
||||
* constructor, inheriting duplicate detection for free rather than reimplementing it.
|
||||
*/
|
||||
export function createPermissionRegistry(
|
||||
declarations: readonly PermissionDeclaration[],
|
||||
@@ -89,7 +83,14 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.authorization.grants.manage",
|
||||
);
|
||||
|
||||
const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
|
||||
/**
|
||||
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||
* exactly these plus every discovered module's declarations, in a fixed, deterministic order,
|
||||
* without reconstructing or guessing Core's own list. `CORE_PERMISSION_REGISTRY` below remains the
|
||||
* registry Core-only code and existing tests use directly.
|
||||
*/
|
||||
export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
|
||||
Object.freeze({
|
||||
baseline: "restricted" as const,
|
||||
description: "Create, list, and revoke another user's explicit authorization grants.",
|
||||
@@ -98,11 +99,12 @@ const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.fr
|
||||
]);
|
||||
|
||||
/**
|
||||
* Core's own permission registry — the trusted source of truth `apps/api`'s authorization
|
||||
* enforcement consults so an HTTP route can never supply its own baseline. Contains exactly the
|
||||
* one Core-owned permission this slice needs; a future module registration mechanism is
|
||||
* deliberately out of scope here and will compose its own declarations through
|
||||
* `createPermissionRegistry` rather than through this constant.
|
||||
* Core's own permission registry, containing exactly the Core-owned permissions declared above.
|
||||
* Production `apps/api` does not consult this constant directly: it enforces requests against the
|
||||
* composed platform registry (`composePlatformPermissionRegistry`,
|
||||
* `core/src/modules/permissions.ts`), which merges these same declarations with every installed
|
||||
* module's. `CORE_PERMISSION_REGISTRY` remains for Core-only callers and tests that need Core's
|
||||
* permissions in isolation, without composing in any module.
|
||||
*/
|
||||
export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry(
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,13 +47,19 @@ export interface ResolvedModuleEntrypoints {
|
||||
readonly jobs?: string;
|
||||
}
|
||||
|
||||
type DeepReadonly<T> = T extends (...arguments_: never[]) => unknown
|
||||
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
||||
// which structurally satisfy `T extends object` — without this check first, the mapped-type
|
||||
// branch below would incorrectly expand a branded string into `String.prototype`'s member shape
|
||||
// and lose the brand entirely, rather than leaving an already-immutable primitive untouched.
|
||||
type DeepReadonly<T> = T extends string | number | boolean | bigint | symbol | null | undefined
|
||||
? T
|
||||
: T extends readonly (infer Item)[]
|
||||
? readonly DeepReadonly<Item>[]
|
||||
: T extends object
|
||||
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
||||
: T;
|
||||
: T extends (...arguments_: never[]) => unknown
|
||||
? T
|
||||
: T extends readonly (infer Item)[]
|
||||
? readonly DeepReadonly<Item>[]
|
||||
: T extends object
|
||||
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
||||
: T;
|
||||
|
||||
export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./discovery.js";
|
||||
export * from "./migration-discovery.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./runtime.js";
|
||||
export * from "./schema-discovery.js";
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
createPermissionRegistry,
|
||||
type PermissionDeclaration,
|
||||
type PermissionRegistry,
|
||||
} from "../authorization/index.js";
|
||||
import type { DiscoveredModule } from "./discovery.js";
|
||||
|
||||
const CORE_PERMISSION_NAMESPACE_PREFIX = "core.";
|
||||
|
||||
export type ModulePermissionCompositionErrorCode = "MODULE_PERMISSION_RESERVED_NAMESPACE";
|
||||
|
||||
/** Thrown only at composition time, never per-request. Carries the offending module and
|
||||
* permission id deliberately — like `PermissionRegistryError`, this is a startup-time
|
||||
* configuration error, not a runtime response, and neither value is sensitive. Never carries a
|
||||
* module directory, manifest path, caught error, or stack detail. */
|
||||
export class ModulePermissionCompositionError extends Error {
|
||||
readonly code: ModulePermissionCompositionErrorCode;
|
||||
readonly moduleId: string;
|
||||
readonly permissionId: string;
|
||||
|
||||
constructor(moduleId: string, permissionId: string) {
|
||||
super(
|
||||
`Module "${moduleId}" cannot declare permission "${permissionId}": the "core." namespace is reserved for Core's own permissions.`,
|
||||
);
|
||||
this.name = "ModulePermissionCompositionError";
|
||||
this.code = "MODULE_PERMISSION_RESERVED_NAMESPACE";
|
||||
this.moduleId = moduleId;
|
||||
this.permissionId = permissionId;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's module-integration-layer composition helper: builds the one platform permission
|
||||
* registry from Core's own declarations (`CORE_PERMISSION_DECLARATIONS`) plus every already
|
||||
* discovered module's manifest-declared permissions. Deliberately lives in `core/src/modules`,
|
||||
* not `core/src/authorization` — the dependency runs one way only, this layer depends on
|
||||
* authorization's `createPermissionRegistry`, and authorization never imports module discovery
|
||||
* or runtime. This is pure, synchronous, in-memory composition over already-discovered,
|
||||
* already-validated data: no filesystem, environment, database, network, or module-code
|
||||
* execution happens here, and the caller (not this function) is responsible for having already
|
||||
* produced a complete, successfully discovered module list (for example via `discoverModules`).
|
||||
*
|
||||
* Declaration order is deterministic and meaningful for duplicate detection: Core's own
|
||||
* declarations first, then each module in the exact order it appears in `modules`, and within a
|
||||
* module its declarations in manifest-array order. Duplicate exact permission ids fail via the
|
||||
* same `PermissionRegistryError` `createPermissionRegistry` already throws for any duplicate;
|
||||
* this function does not reimplement or duplicate that detection.
|
||||
*
|
||||
* `core.*` is reserved for Core's own permissions. A discovered module contributing any
|
||||
* `core.`-prefixed permission id — regardless of that module's own id, and regardless of whether
|
||||
* it happens to exactly collide with an existing declaration — fails composition with
|
||||
* `ModulePermissionCompositionError` rather than silently registering or silently colliding. This
|
||||
* check runs before a declaration is ever added to the list `createPermissionRegistry` sees, so it
|
||||
* is also what actually catches a module attempting to collide with a Core-owned id specifically:
|
||||
* every Core-owned id is itself `core.`-prefixed, module ids may never contain a dot (so only a
|
||||
* module whose own id is literally `"core"` can produce a `core.`-prefixed permission at all), and
|
||||
* a genuine module-vs-module collision is structurally unreachable through real discovery — module
|
||||
* ids are unique (discovery itself rejects a duplicate) and every permission id is namespaced by
|
||||
* its own module's id. `PermissionRegistryError` therefore never fires for a real, validly
|
||||
* discovered installed-module set; `createPermissionRegistry`'s duplicate rejection remains here as
|
||||
* a defensive, unconditional guarantee regardless, and is exercised directly with deliberately
|
||||
* conflicting test data rather than through a scenario real discovery could ever produce.
|
||||
*/
|
||||
export function composePlatformPermissionRegistry(
|
||||
modules: readonly DiscoveredModule[],
|
||||
): PermissionRegistry {
|
||||
const declarations: PermissionDeclaration[] = [...CORE_PERMISSION_DECLARATIONS];
|
||||
|
||||
for (const module of modules) {
|
||||
for (const declaration of module.manifest.permissions) {
|
||||
if (declaration.id.startsWith(CORE_PERMISSION_NAMESPACE_PREFIX)) {
|
||||
throw new ModulePermissionCompositionError(module.manifest.id, declaration.id);
|
||||
}
|
||||
declarations.push(declaration);
|
||||
}
|
||||
}
|
||||
|
||||
return createPermissionRegistry(declarations);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { parsePermissionId } from "@ekklesia/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
createPermissionRegistry,
|
||||
PermissionRegistryError,
|
||||
@@ -92,3 +93,21 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||
it("is Core's own declaration list, exported separately from the registry in deeply immutable form", () => {
|
||||
expect(CORE_PERMISSION_DECLARATIONS).toEqual([
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: "Create, list, and revoke another user's explicit authorization grants.",
|
||||
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
},
|
||||
]);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => {
|
||||
expect(CORE_PERMISSION_DECLARATIONS).toEqual(CORE_PERMISSION_REGISTRY.declarations);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ async function createModule(
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 2,
|
||||
manifestVersion: 3,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -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
|
||||
|
||||
+123
-2
@@ -1,5 +1,6 @@
|
||||
import type { ModuleManifest } from "@ekklesia/contracts";
|
||||
import { parsePermissionId, type ModuleManifest } from "@ekklesia/contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||
import {
|
||||
type DoctorCheck,
|
||||
type DoctorDependencies,
|
||||
@@ -21,6 +22,7 @@ import type {
|
||||
ModuleMigrationAggregate,
|
||||
ModuleSchemaAggregate,
|
||||
} from "../src/modules/index.js";
|
||||
import { ModulePermissionCompositionError } from "../src/modules/permissions.js";
|
||||
|
||||
const checksumA = "a".repeat(64);
|
||||
const checksumB = "b".repeat(64);
|
||||
@@ -39,7 +41,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
|
||||
},
|
||||
entrypoints: { server: "./dist/server.js" },
|
||||
id,
|
||||
manifestVersion: 2,
|
||||
manifestVersion: 3,
|
||||
name: `${id} module`,
|
||||
permissions: [],
|
||||
storage: { buckets: [] },
|
||||
@@ -125,6 +127,11 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
|
||||
|
||||
function dependencies(overrides: Partial<DoctorDependencies> = {}): 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(
|
||||
|
||||
@@ -31,7 +31,7 @@ async function createModulesDirectory(): Promise<string> {
|
||||
|
||||
function manifestFor(id: string): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
manifestVersion: 3,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
@@ -40,7 +40,9 @@ function manifestFor(id: string): Record<string, unknown> {
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
capabilities: [{ id: `${id}.records`, version: "1.0.0" }],
|
||||
permissions: [`${id}.records.read`],
|
||||
permissions: [
|
||||
{ id: `${id}.records.read`, baseline: "restricted", description: "Read records." },
|
||||
],
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
},
|
||||
@@ -151,6 +153,16 @@ describe("module discovery", () => {
|
||||
expect(Object.isFrozen(modules[1]?.manifest)).toBe(true);
|
||||
const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>;
|
||||
expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError);
|
||||
|
||||
expect(modules[1]?.manifest.permissions).toEqual([
|
||||
{ id: "calendar.records.read", baseline: "restricted", description: "Read records." },
|
||||
]);
|
||||
expect(Object.isFrozen(modules[1]?.manifest.permissions)).toBe(true);
|
||||
expect(Object.isFrozen(modules[1]?.manifest.permissions[0])).toBe(true);
|
||||
const mutablePermissions = modules[1]?.manifest.permissions as unknown as Array<{
|
||||
id: string;
|
||||
}>;
|
||||
expect(() => mutablePermissions.push({ id: "tampered" })).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("aggregates malformed packages and manifests without partial discovery", async () => {
|
||||
@@ -215,7 +227,9 @@ describe("module discovery", () => {
|
||||
...manifestFor("one"),
|
||||
id: "shared",
|
||||
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||
permissions: ["shared.records.read"],
|
||||
permissions: [
|
||||
{ id: "shared.records.read", baseline: "restricted", description: "Read records." },
|
||||
],
|
||||
},
|
||||
packageJson: {
|
||||
name: "@test/duplicate",
|
||||
@@ -229,7 +243,9 @@ describe("module discovery", () => {
|
||||
...manifestFor("two"),
|
||||
id: "shared",
|
||||
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||
permissions: ["shared.records.read"],
|
||||
permissions: [
|
||||
{ id: "shared.records.read", baseline: "restricted", description: "Read records." },
|
||||
],
|
||||
},
|
||||
packageJson: {
|
||||
name: "@test/duplicate",
|
||||
|
||||
@@ -65,7 +65,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
manifestVersion: 3,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { type PermissionBaseline, parseModuleManifest } from "@ekklesia/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
PermissionRegistryError,
|
||||
} from "../src/authorization/index.js";
|
||||
import type { DiscoveredModule } from "../src/modules/discovery.js";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
ModulePermissionCompositionError,
|
||||
} from "../src/modules/permissions.js";
|
||||
|
||||
interface PermissionInput {
|
||||
readonly id: string;
|
||||
readonly baseline: PermissionBaseline;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/** A minimal, in-memory `DiscoveredModule` double — no filesystem, no real discovery. The manifest
|
||||
* is still built through `parseModuleManifest`, so a fixture that would not actually pass manifest
|
||||
* validation cannot silently misrepresent what real discovery would ever produce. Only `manifest`
|
||||
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
|
||||
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 3,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
permissions,
|
||||
entrypoints: {
|
||||
// Deliberately a path nothing will ever resolve, proving composition never imports or
|
||||
// otherwise touches a module's server entrypoint.
|
||||
server: "./dist/does-not-exist/index.js",
|
||||
},
|
||||
});
|
||||
return {
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: { server: `/modules/${id}/dist/does-not-exist/index.js` },
|
||||
manifest,
|
||||
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("composePlatformPermissionRegistry", () => {
|
||||
it("composes a Core-only registry when no modules are supplied", () => {
|
||||
const registry = composePlatformPermissionRegistry([]);
|
||||
|
||||
expect(registry.declarations).toEqual(CORE_PERMISSION_DECLARATIONS);
|
||||
expect(registry.has(CORE_AUTHORIZATION_GRANTS_MANAGE)).toBe(true);
|
||||
});
|
||||
|
||||
it("composes Core and multiple discovered modules' declarations into one registry", () => {
|
||||
const calendar = fakeModule("calendar", [
|
||||
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||
]);
|
||||
const people = fakeModule("people", [
|
||||
{ id: "people.directory.read", baseline: "authenticated", description: "Read directory." },
|
||||
]);
|
||||
|
||||
const registry = composePlatformPermissionRegistry([calendar, people]);
|
||||
|
||||
expect(registry.has(CORE_AUTHORIZATION_GRANTS_MANAGE)).toBe(true);
|
||||
expect(registry.has("calendar.events.read")).toBe(true);
|
||||
expect(registry.has("people.directory.read")).toBe(true);
|
||||
expect(registry.declarations).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||
const calendar = fakeModule("calendar", [
|
||||
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||
{ id: "calendar.events.write", baseline: "restricted", description: "Write events." },
|
||||
]);
|
||||
const people = fakeModule("people", [
|
||||
{ id: "people.directory.read", baseline: "authenticated", description: "Read directory." },
|
||||
]);
|
||||
|
||||
const registry = composePlatformPermissionRegistry([calendar, people]);
|
||||
|
||||
expect(registry.declarations.map((declaration) => declaration.id)).toEqual([
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
"calendar.events.read",
|
||||
"calendar.events.write",
|
||||
"people.directory.read",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"public",
|
||||
"authenticated",
|
||||
"restricted",
|
||||
] as const)("preserves the exact %s baseline and description of a module declaration", (baseline) => {
|
||||
const calendar = fakeModule("calendar", [
|
||||
{ id: "calendar.events.read", baseline, description: "Read calendar events." },
|
||||
]);
|
||||
|
||||
const registry = composePlatformPermissionRegistry([calendar]);
|
||||
|
||||
expect(registry.get("calendar.events.read")).toEqual({
|
||||
id: "calendar.events.read",
|
||||
baseline,
|
||||
description: "Read calendar events.",
|
||||
});
|
||||
});
|
||||
|
||||
it("still fails closed for a permission no module or Core ever declared", () => {
|
||||
const registry = composePlatformPermissionRegistry([]);
|
||||
|
||||
expect(registry.has("calendar.events.read")).toBe(false);
|
||||
expect(registry.get("calendar.events.read")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails composition on an exact duplicate permission id supplied by two module entries", () => {
|
||||
// A permission id must start with its own module's id, so a genuine cross-module collision is
|
||||
// only reachable if the supplied list itself contains the same module id twice — real
|
||||
// discovery already guarantees module-id uniqueness (`MODULE_ID_DUPLICATE`), but composition
|
||||
// re-derives duplicate detection from the flat declaration list regardless, rather than
|
||||
// trusting that upstream guarantee alone.
|
||||
const first = fakeModule("calendar", [
|
||||
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||
]);
|
||||
const second = fakeModule("calendar", [
|
||||
{ id: "calendar.events.read", baseline: "public", description: "Also read events." },
|
||||
]);
|
||||
|
||||
expect(() => composePlatformPermissionRegistry([first, second])).toThrow(
|
||||
PermissionRegistryError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a module contributing a permission in the reserved core.* namespace, without leaking a module path", () => {
|
||||
// Only a module whose own id is literally "core" can produce a "core."-prefixed permission
|
||||
// that still passes the manifest's own module-namespace check — this proves composition
|
||||
// catches that case rather than trusting the manifest's self-consistency alone.
|
||||
const impostor = fakeModule("core", [
|
||||
{ id: "core.rogue.permission", baseline: "restricted", description: "Not really Core's." },
|
||||
]);
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
composePlatformPermissionRegistry([impostor]);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(ModulePermissionCompositionError);
|
||||
expect(captured).toMatchObject({
|
||||
code: "MODULE_PERMISSION_RESERVED_NAMESPACE",
|
||||
moduleId: "core",
|
||||
permissionId: "core.rogue.permission",
|
||||
});
|
||||
const publicText = String(captured);
|
||||
expect(publicText).not.toContain("/modules/core");
|
||||
expect(publicText).not.toContain("ekklesia.module.json");
|
||||
});
|
||||
|
||||
it("rejects a reserved-namespace collision even when it would not otherwise duplicate an existing id", () => {
|
||||
const impostor = fakeModule("core", [
|
||||
{
|
||||
id: "core.authorization.grants.manage",
|
||||
baseline: "restricted",
|
||||
description: "Attempting to shadow Core's own permission.",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() => composePlatformPermissionRegistry([impostor])).toThrow(
|
||||
ModulePermissionCompositionError,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a module's permissions from the resulting registry once that module is absent from the supplied list", () => {
|
||||
const calendar = fakeModule("calendar", [
|
||||
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||
]);
|
||||
|
||||
const withCalendar = composePlatformPermissionRegistry([calendar]);
|
||||
expect(withCalendar.has("calendar.events.read")).toBe(true);
|
||||
|
||||
const withoutCalendar = composePlatformPermissionRegistry([]);
|
||||
expect(withoutCalendar.has("calendar.events.read")).toBe(false);
|
||||
});
|
||||
|
||||
it("performs no I/O: composition is synchronous and never imports a module's entrypoint", () => {
|
||||
const calendar = fakeModule("calendar", [
|
||||
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||
]);
|
||||
|
||||
// A synchronous return (not a Promise) from a fixture whose entrypoint deliberately resolves
|
||||
// to nothing on disk is direct evidence no filesystem or module-code operation occurred.
|
||||
const result = composePlatformPermissionRegistry([calendar]);
|
||||
expect(result).not.toBeInstanceOf(Promise);
|
||||
expect(result.has("calendar.events.read")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not mutate the supplied module list or Core's own declaration list", () => {
|
||||
const calendar = fakeModule("calendar", [
|
||||
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||
]);
|
||||
const modules = Object.freeze([calendar]);
|
||||
const coreDeclarationsBefore = [...CORE_PERMISSION_DECLARATIONS];
|
||||
|
||||
composePlatformPermissionRegistry(modules);
|
||||
|
||||
expect(modules).toEqual([calendar]);
|
||||
expect(CORE_PERMISSION_DECLARATIONS).toEqual(coreDeclarationsBefore);
|
||||
});
|
||||
|
||||
it("returns a registry and declarations that remain immutable, including module-contributed ones", () => {
|
||||
const calendar = fakeModule("calendar", [
|
||||
{ id: "calendar.events.read", baseline: "restricted", description: "Read events." },
|
||||
]);
|
||||
|
||||
const registry = composePlatformPermissionRegistry([calendar]);
|
||||
|
||||
expect(Object.isFrozen(registry)).toBe(true);
|
||||
expect(Object.isFrozen(registry.declarations)).toBe(true);
|
||||
for (const declaration of registry.declarations) {
|
||||
expect(Object.isFrozen(declaration)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
manifestVersion: 3,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -41,7 +41,7 @@ function manifestFor(
|
||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
manifestVersion: 3,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -19,6 +19,15 @@ registry, which conflicts with install-by-cloning discovery.
|
||||
|
||||
## Decision
|
||||
|
||||
> **Partial correction — see [ADR 0014](0014-module-permission-declarations-and-platform-registry.md).**
|
||||
> `manifestVersion: 2` and the permission-string-array shape described immediately below (the
|
||||
> `permissions` field of the static manifest) were superseded by ADR 0014's `manifestVersion: 3`,
|
||||
> which represents each permission as a complete `{id, baseline, description}` declaration instead
|
||||
> of a bare identifier string. Every other decision in this ADR — versioned capabilities,
|
||||
> `defineModuleRuntime`, the compiled-runtime production loading boundary, and registration/lifecycle
|
||||
> ordering — remains accepted and unchanged. This section is left as originally accepted, as the
|
||||
> historical record of the manifest's prior shape.
|
||||
|
||||
`ekklesia.module.json` remains the one canonical static manifest and moves to
|
||||
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
|
||||
semantic version. Required and optional capability dependencies map identifiers to semantic-version
|
||||
|
||||
@@ -194,6 +194,13 @@ module permission discovery/registration (a mechanism for a module to contribute
|
||||
declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and
|
||||
remain later work.
|
||||
|
||||
> **Update — see [ADR 0014](0014-module-permission-declarations-and-platform-registry.md).** Module
|
||||
> permission discovery/registration, deferred above, was implemented by ADR 0014: a module manifest
|
||||
> declares permissions in the same `PermissionDeclaration` shape this ADR's registry uses, and
|
||||
> `apps/api` composes Core's declarations with every installed module's into the one registry every
|
||||
> request is enforced against. Roles/groups/teams/tenants, owner-management HTTP, and service
|
||||
> accounts/API tokens remain deferred.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- Representing platform-owner status as a special grant row (a synthetic `*` permission, or a
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# 0014: Module permission declarations and platform permission registry
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-24
|
||||
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md)
|
||||
- Supersedes (partial): [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) — only
|
||||
its `manifestVersion: 2` and bare-permission-string decision. Capabilities, the compiled-runtime
|
||||
production loading boundary, and registration/lifecycle ordering established in ADR 0006 remain
|
||||
accepted and unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0013 established Core's authorization foundation — schema, evaluator, store, permission
|
||||
registry, platform-owner bootstrap, and HTTP enforcement — but explicitly deferred module
|
||||
permission registration: a module manifest (ADR 0006, `manifestVersion: 2`) could declare a
|
||||
`permissions` array, but only as bare namespaced strings. That was enough to validate a namespace
|
||||
at manifest-parse time, but not enough to *register* anything: Core's authorization registry needs
|
||||
a trusted baseline and an operator-facing description for every permission it enforces, and a bare
|
||||
string carries neither. Phase 5A closes that gap in two passes on this branch: pass 1
|
||||
(`feat: declare module permissions`) established the shared declaration contract, manifest version
|
||||
3, and a pure composition function; pass 2 (`feat: integrate module authorization`) wired the
|
||||
composed registry into `apps/api`'s production startup and its existing enforcement/explicit-grant
|
||||
HTTP surface. This ADR records the durable architecture both passes established.
|
||||
|
||||
## Decision
|
||||
|
||||
### `PermissionDeclaration` is the one shared, contracts-layer definition
|
||||
|
||||
`packages/contracts/src/authorization.ts`'s `permissionDeclarationSchema` is the single
|
||||
authoritative shape of a registered permission declaration — `id` (a branded `PermissionId`),
|
||||
`baseline` (`public`/`authenticated`/`restricted`, the evaluator's trusted baseline), and
|
||||
`description` (required, trimmed, non-empty, capped at 240 characters). `.strict()` rejects any
|
||||
other property. Both Core's own authorization registry
|
||||
(`core/src/authorization/permissions.ts`) and a module manifest's `permissions` array
|
||||
(`packages/contracts/src/module-manifest.ts`) consume this exact type — Core does not declare a
|
||||
second, independently maintained interface, and a module cannot smuggle extra fields into a
|
||||
declaration Core treats as trusted. `description` is informational only: audit and operator UI
|
||||
text, never read or interpreted by the evaluator, the registry, or enforcement — changing it can
|
||||
never change what a permission allows.
|
||||
|
||||
### Module manifests are inert JSON and the sole permission-declaration source
|
||||
|
||||
A module's `ekklesia.module.json` remains inert, static data Core parses without executing any
|
||||
module code — consistent with ADR 0006's discovery model. Its `permissions` array is the only
|
||||
place a module declares permissions; nothing in `defineModuleRuntime` (Module SDK,
|
||||
`packages/module-sdk/src/define-module-runtime.ts`) repeats, duplicates, or otherwise re-declares
|
||||
permissions, baselines, or descriptions — exactly like it already does not repeat capabilities,
|
||||
dependencies, storage requests, or entrypoints (ADR 0006). Core never imports or runs a module's
|
||||
server entrypoint, job, or lifecycle hook to discover its permissions; only the manifest is read.
|
||||
|
||||
### Manifest version 3 replaces version 2's bare permission strings
|
||||
|
||||
Representing a permission as a complete declaration instead of a bare identifier string is an
|
||||
incompatible manifest shape change, so the canonical manifest moves to `manifestVersion: 3`
|
||||
(`z.literal(3)`). Version 2 — and any other version — fails validation outright; there is no
|
||||
compatibility adapter, no automatic conversion from a string array to declarations, and no dual
|
||||
acceptance of both shapes. A module author must rewrite `permissions` explicitly. `permissions`
|
||||
still defaults to an empty array when the field is omitted entirely, exactly as version 2 did.
|
||||
|
||||
### Each module owns exactly its own namespace; `core.*` is reserved
|
||||
|
||||
A permission id declared in a module's manifest must begin with `<module-id>.`, checked the same
|
||||
way a capability id already is (ADR 0006). Module ids never contain a dot (`moduleIdSchema` is
|
||||
plain kebab-case), so the only module whose manifest could ever declare a `core.`-prefixed
|
||||
permission and still pass this per-manifest namespace check is one whose own id is literally
|
||||
`"core"` — `core.*` itself remains reserved for Core's own permissions, enforced independently at
|
||||
composition time (below). Duplicate permission ids within one manifest fail manifest validation at
|
||||
the exact issue path `permissions[index].id`, mirroring the existing capability and storage-bucket
|
||||
duplicate checks.
|
||||
|
||||
### Core and installed-module declarations compose into one immutable registry
|
||||
|
||||
`composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) builds the platform's one
|
||||
permission registry from Core's own declarations (`CORE_PERMISSION_DECLARATIONS`) plus every
|
||||
already-discovered module's declarations. It reuses `createPermissionRegistry`
|
||||
(`core/src/authorization/permissions.ts`) rather than reimplementing registry construction or
|
||||
duplicate detection — there is no second, module-specific registry type. Order is deterministic:
|
||||
Core's declarations first, then each module in the exact order it appears in the caller-supplied
|
||||
list, then each module's own declarations in manifest-array order. The resulting registry, and
|
||||
every declaration it exposes, is immutable (`Object.freeze`), matching `createPermissionRegistry`'s
|
||||
existing guarantee. Composition is pure, synchronous, in-memory work over data the caller already
|
||||
discovered: no filesystem, environment, database, network, or module-code operation happens inside
|
||||
it.
|
||||
|
||||
`core/src/authorization` continues to know nothing about modules — it does not import module
|
||||
discovery or runtime. `core/src/modules/permissions.ts` is the one-directional module-integration
|
||||
layer that depends on `core/src/authorization`, never the reverse.
|
||||
|
||||
### The structural collision guarantee
|
||||
|
||||
A discovered module contributing any `core.`-prefixed permission id fails composition with
|
||||
`ModulePermissionCompositionError` (`MODULE_PERMISSION_RESERVED_NAMESPACE`) — checked, and thrown,
|
||||
before that declaration is ever added to the list `createPermissionRegistry` sees. This is a
|
||||
deliberate architectural guarantee, not an incidental test limitation, and it follows from three
|
||||
facts holding simultaneously: real module discovery rejects a duplicate module id
|
||||
(`MODULE_ID_DUPLICATE`), so module ids are unique; a manifest's own namespace check requires every
|
||||
permission id to begin with `<module-id>.`; and a manifest already rejects a duplicate permission
|
||||
id within itself. Together, these mean a module can only ever produce a `core.`-prefixed permission
|
||||
if its own id is literally `"core"` — and when it does, that declaration is caught by the
|
||||
reserved-namespace check specifically, never by `createPermissionRegistry`'s generic duplicate
|
||||
detection (`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`). The same three
|
||||
facts also mean a genuine *module-vs-module* exact permission-id collision cannot arise from a
|
||||
valid, really-discovered installed-module set at all — two different module ids can never produce
|
||||
the identical permission string. `createPermissionRegistry`'s own duplicate rejection remains in
|
||||
place regardless, as an unconditional, defense-in-depth guarantee of the underlying registry
|
||||
primitive; both Core's test suite and this composer's own tests exercise it directly with
|
||||
deliberately conflicting internal test data (not reachable through real discovery), specifically to
|
||||
prove the guarantee holds even though production traffic cannot currently trigger it. Both error
|
||||
families carry only a module id and/or permission id — never a module directory, manifest path,
|
||||
caught cause, or stack.
|
||||
|
||||
### Module discovery and permission composition precede all database and HTTP construction
|
||||
|
||||
`apps/api`'s `startApi()` (`apps/api/src/server.ts`) composes the platform registry
|
||||
(`composeStartupPermissionRegistry`, `apps/api/src/platform-permissions.ts`) as its first step,
|
||||
strictly before constructing the shared PostgreSQL pool, Better Auth, the authorization store and
|
||||
evaluator, the Hono application, or binding the HTTP server. A discovery or composition failure —
|
||||
an invalid manifest, an incompatible dependency graph, a reserved-namespace claim, or a duplicate
|
||||
declaration — therefore has nothing to close: it propagates directly out of `startApi()`, and
|
||||
startup never falls back to a Core-only registry. Modules are discovered exactly once for this
|
||||
composition; nothing re-discovers them a second time for permissions specifically.
|
||||
|
||||
Repository and package-version information is derived from the installed repository's own layout —
|
||||
never `process.cwd()`, which a package-scoped test run does not set to the repository root. The
|
||||
repository root is resolved from the composing module's own compiled/source file location (a fixed
|
||||
number of directory levels up, identical whether running `apps/api/dist/*.js` or, under a test
|
||||
runner, `apps/api/src/*.ts`); the platform and Module SDK versions are read from their real
|
||||
`package.json` files, only when startup actually executes — never merely by importing the API's
|
||||
server, config, or app modules, which remain side-effect-free at import time.
|
||||
|
||||
### API enforcement and explicit grants use one registry for Core and module permissions alike
|
||||
|
||||
`createApiApp` requires the composed platform registry as an injected authorization option;
|
||||
`startApi` is the only production caller, and it always supplies the registry
|
||||
`composeStartupPermissionRegistry` produced. There is no `CORE_PERMISSION_REGISTRY` fallback
|
||||
anywhere in `apps/api` — not in `createApiApp`, not in `createGrantsRoutes`, not in the
|
||||
`requirePermission` middleware. `requirePermission` has no notion of "Core" versus "module"
|
||||
permissions; it resolves a route's declared `PermissionId` and its trusted baseline from whichever
|
||||
registry it is given, denies before principal resolution or evaluator access when the permission is
|
||||
unregistered, and never accepts a baseline, actor, or any other authorization-relevant field from
|
||||
client input. A module's `public`, `authenticated`, or `restricted` declaration is therefore
|
||||
enforced identically to Core's own `core.authorization.grants.manage`, through the identical
|
||||
evaluator and grant path, and — because registration and evaluator shape validation remain the two
|
||||
separate, independent fail-closed layers ADR 0013 established — a well-formed but unregistered
|
||||
permission (module or Core) still receives a sanitized `403` with no store or audit write, whether
|
||||
the request is a permission check or an explicit-grant creation attempt on `POST /api/v1/grants`.
|
||||
|
||||
### The registry is a static startup snapshot
|
||||
|
||||
There is no hot installation, removal, or dynamic mutation of the composed registry while `apps/api`
|
||||
is running. Installing, removing, or editing a module's manifest takes effect only on the next API
|
||||
restart. Removing a module from the next startup's discovered inventory makes its permission
|
||||
unknown to new authorization checks and new explicit grants — it denies exactly like any other
|
||||
unregistered permission — but never deletes, revokes, or otherwise modifies a historical grant or
|
||||
audit row already recorded for that permission. A previously granted, still-unrevoked explicit
|
||||
grant remains listed through `GET /api/v1/grants`, and revoking it explicitly remains possible,
|
||||
since revocation is keyed to the grant's own id and source kind, not to whether its permission is
|
||||
currently registered. What happens if the exact same permission id is later reinstalled or
|
||||
reactivated in a future startup — whether a preserved, still-active grant becomes effective again
|
||||
automatically — is **explicitly not decided by this ADR** and remains deferred; nothing here should
|
||||
be read as a guarantee either way.
|
||||
|
||||
### Doctor and `modules:validate` compose the same inventory
|
||||
|
||||
`pnpm doctor`'s `modules.permissions` check and `pnpm modules:validate` both call
|
||||
`composePlatformPermissionRegistry` over the module list their own already-completed discovery
|
||||
produced — neither rediscovers modules solely to check permissions. A structurally valid manifest
|
||||
that claims the `core.*` namespace, or that would produce a duplicate declaration, now fails before
|
||||
that module is considered installable (`modules:validate`) or reported healthy (`doctor`).
|
||||
|
||||
### Test-only discovery overrides are not a production configuration surface
|
||||
|
||||
`composeStartupPermissionRegistry`'s `modulesDirectoryOverride` and `discoveredModulesOverride`
|
||||
exist solely so tests can exercise discovery and composition against controlled fixtures or
|
||||
deliberately conflicting data — the latter specifically because a genuine module-vs-module
|
||||
duplicate is otherwise unreachable through real discovery, as established above. Neither is read
|
||||
from an environment variable, an HTTP header, or any other client- or deployment-controlled input;
|
||||
`main()`, the executable entry point, never sets either.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Runtime, code-based permission registration** (a module registering permissions imperatively
|
||||
from its server entrypoint or a registration hook) was rejected: it would require executing
|
||||
module code to discover what a module can even offer, breaking the inert-manifest discovery
|
||||
model ADR 0006 already established and that Doctor and `modules:validate` depend on for
|
||||
offline, side-effect-free validation.
|
||||
- **A second, module-specific registry implementation** was rejected in favor of reusing
|
||||
`createPermissionRegistry` — a second implementation would need to reimplement duplicate
|
||||
detection, immutability, and fail-closed lookup independently, with no guarantee the two stayed
|
||||
behaviorally identical.
|
||||
- **Falling back to `CORE_PERMISSION_REGISTRY` when discovery or composition fails** was rejected:
|
||||
it would let the API start in a state where module-declared permissions silently do not exist,
|
||||
which is indistinguishable from "no modules were ever installed" and would mask a real
|
||||
configuration error as ordinary, healthy startup.
|
||||
- **Dynamic, mutable, or hot-reloaded registration** (updating the registry while the process
|
||||
runs, without a restart) was rejected for this phase: it introduces concurrent-mutation and
|
||||
partial-update hazards this phase does not need to solve, and a static snapshot is sufficient
|
||||
for the current deployment model.
|
||||
- **Silently accepting version-2 manifests** (auto-converting a bare permission string into a
|
||||
declaration with a synthesized baseline/description) was rejected: any synthesized baseline
|
||||
would be a guess Core cannot trust, and the whole point of requiring a real declaration is that
|
||||
the baseline and description come from the module author, not from Core inventing them.
|
||||
- **Deleting or automatically revoking a module's grants when the module disappears** was
|
||||
rejected: it would destroy an audit trail that must remain queryable regardless of a module's
|
||||
current installation state, and revocation is a deliberate, actor-attributed action elsewhere in
|
||||
this system (ADR 0013) — an uninstall is not that action.
|
||||
- **Reading a permission's baseline from HTTP request input** (letting a caller assert which
|
||||
baseline to check) was already rejected by ADR 0013 and remains rejected here: the baseline is
|
||||
never anything but the registry's own trusted value for a request-supplied `PermissionId`.
|
||||
|
||||
## Consequences
|
||||
|
||||
A module gains a real, enforceable permission the moment its manifest is written correctly and the
|
||||
module is discovered — no separate registration step, no code to write beyond the manifest itself.
|
||||
Core's own permissions and every module's permissions share one enforcement mechanism end to end:
|
||||
one registry, one middleware, one explicit-grant HTTP API. The reserved `core.*` namespace and the
|
||||
structural collision guarantee mean a module can never accidentally or deliberately shadow a
|
||||
Core-owned permission without that attempt being caught, sanitized, and reported as exactly what it
|
||||
is. The cost is the one explicitly deferred question this ADR leaves open — reinstall/reactivation
|
||||
semantics for a permission whose module was previously removed — and the operational fact that
|
||||
every module inventory change requires an API restart to take effect, which is acceptable for the
|
||||
current deployment model but not the final word on it.
|
||||
|
||||
## Deferred
|
||||
|
||||
Module HTTP route-registration contracts (a module mounting its own endpoints); a
|
||||
`ModuleRuntimeContext` authorization service; relationship-derived grant creation (a roster or
|
||||
similar module operation using `sourceKind: "relationship"`, not the generic explicit-grant API);
|
||||
reinstall/reactivation policy for a permission whose owning module was previously removed;
|
||||
roles, groups, teams, and tenants; an owner-management HTTP endpoint; service accounts and API
|
||||
tokens for non-browser automation; module installation/uninstallation state and hot reload; Surfaces
|
||||
and UI; and workflow/event automation. None of these are implemented by, or required for, the
|
||||
architecture this ADR records.
|
||||
@@ -22,3 +22,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted
|
||||
- [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted
|
||||
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
|
||||
- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
# Authorization
|
||||
|
||||
Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into
|
||||
`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the
|
||||
platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it.
|
||||
See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the
|
||||
HTTP transport it is mounted into, and [ADR
|
||||
0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable
|
||||
architectural decisions this page describes in more implementation detail.
|
||||
Phase 4C establishes Core's authorization foundation and wires it into `apps/api` over HTTP. Phase
|
||||
5A extends that same system so an installed module's declared permissions are enforced and
|
||||
grantable through the identical mechanism as Core's own. This page covers all of it: the
|
||||
schema/evaluator/store/registry Core owns, the platform-owner bootstrap operation, the
|
||||
enforcement/endpoints `apps/api` exposes, and how module manifests contribute to the one platform
|
||||
permission registry. See [Identity](identity.md) for the identity foundation this builds on,
|
||||
[API](api.md) for the HTTP transport it is mounted into, [Module development](../module-development/README.md)
|
||||
for the manifest shape that declares module permissions, and
|
||||
[ADR 0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) /
|
||||
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md)
|
||||
for the durable architectural decisions this page describes in more implementation detail.
|
||||
|
||||
## What this is, and is not
|
||||
|
||||
After this slice, an operator can bootstrap exactly one platform owner for a deployment, a
|
||||
platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization
|
||||
grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of
|
||||
silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants;
|
||||
there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not
|
||||
exposed at all in this slice); there are no service accounts or API tokens, so only
|
||||
cookie-authenticated browser callers can reach the mutation routes; and relationship-derived
|
||||
grants (for example, automatic access from a roster assignment) cannot yet be created by anything
|
||||
in this repository — the generic grants API structurally cannot create or revoke them either.
|
||||
An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone
|
||||
they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any
|
||||
registered permission, Core-owned or module-owned; and every enforced route fails closed on
|
||||
infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage
|
||||
roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or
|
||||
re-granting platform-owner status is not exposed at all); there are no service accounts or API
|
||||
tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot
|
||||
yet mount its own HTTP routes; and relationship-derived grants (for example, automatic access from
|
||||
a roster assignment) cannot yet be created by anything in this repository — the generic grants API
|
||||
structurally cannot create or revoke them either.
|
||||
|
||||
## Core authorization schema and store
|
||||
|
||||
@@ -31,10 +36,19 @@ permission registry, the bootstrap operation, and HTTP enforcement.
|
||||
|
||||
## Permission registry
|
||||
|
||||
`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable,
|
||||
composition-time-built `PermissionRegistry` distinguishing a *registered declaration*
|
||||
(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties
|
||||
matter:
|
||||
`PermissionDeclaration` (`packages/contracts/src/authorization.ts`) is the one authoritative,
|
||||
strict Zod-validated shape of a *registered declaration* — exactly `{id, baseline, description}`,
|
||||
nothing more — shared by Core's authorization registry and a module manifest's `permissions` array.
|
||||
Core does not declare its own separate interface for this; `core/src/authorization/permissions.ts`
|
||||
imports and re-exports the contracts type so existing `@ekklesia/core/authorization` consumers keep
|
||||
importing it from the same place. `id` is a branded `PermissionId`, `baseline` is
|
||||
`public`/`authenticated`/`restricted`, and `description` is required, trimmed, non-empty operator
|
||||
and audit-facing text up to 240 characters — informational only; nothing in authorization reads or
|
||||
interprets it.
|
||||
|
||||
`core/src/authorization/permissions.ts` defines the smallest possible registry on top of that
|
||||
shape: an immutable, composition-time-built `PermissionRegistry` distinguishing a *registered*
|
||||
declaration from a merely well-formed `PermissionId` string. Two properties matter:
|
||||
|
||||
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
|
||||
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
|
||||
@@ -44,14 +58,121 @@ matter:
|
||||
and return `undefined`/`false` for anything malformed or simply not registered — there is no
|
||||
"trust this permission id because it looks right" path.
|
||||
|
||||
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission:
|
||||
Permission matching is always exact string equality, both in the registry and in the evaluator's
|
||||
grant lookup — there is no wildcard, prefix, or namespace-inheritance matching anywhere; the
|
||||
required dot-namespacing in a `PermissionId` (`core.authorization.grants.manage`,
|
||||
`calendar.events.read`) is purely a naming convention for readability and collision-avoidance.
|
||||
|
||||
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission, and
|
||||
remains what Core-only code and existing tests use directly. `CORE_PERMISSION_DECLARATIONS` is the
|
||||
same declarations exported separately in deeply immutable form, for the module composition helper
|
||||
below to build on without reconstructing Core's own list:
|
||||
|
||||
| Permission | Baseline | Description |
|
||||
| --- | --- | --- |
|
||||
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
||||
|
||||
Module-owned permission declarations and a discovery/composition path for them are out of scope
|
||||
for this slice (deferred to the Module SDK work).
|
||||
## Module permission composition
|
||||
|
||||
A module manifest's `permissions` array (`manifestVersion: 3`,
|
||||
[Module development](../module-development/README.md#canonical-manifest)) declares permissions in
|
||||
the exact same `PermissionDeclaration` shape — but declaring one there only proves it is
|
||||
well-formed and correctly namespaced under the module's own id; it does not, by itself, register
|
||||
anything. `composePlatformPermissionRegistry` (`core/src/modules/permissions.ts`) is the trusted
|
||||
composer: given an already-discovered module list (for example `discoverModules`'s result), it
|
||||
builds one combined `PermissionRegistry` from `CORE_PERMISSION_DECLARATIONS` followed by every
|
||||
module's declarations, in discovery order, using the same `createPermissionRegistry` constructor —
|
||||
not a second registry implementation. Declaration order is deterministic: Core first, then each
|
||||
module in the order it appears in the supplied list, then each module's own declarations in
|
||||
manifest-array order. A duplicate exact permission id anywhere in that combined list fails
|
||||
composition the same way a duplicate would fail `createPermissionRegistry` directly — since a
|
||||
permission id must start with its own module's id, this is only reachable across two entries that
|
||||
share the same module id, which real discovery already rules out (`MODULE_ID_DUPLICATE`);
|
||||
composition still re-derives the check rather than trusting that guarantee alone.
|
||||
|
||||
`core.*` is reserved for Core's own permissions. A discovered module contributing any
|
||||
`core.`-prefixed permission id fails composition with `ModulePermissionCompositionError`
|
||||
(`MODULE_PERMISSION_RESERVED_NAMESPACE`) before duplicate detection ever runs — since every
|
||||
Core-owned id is itself `core.`-prefixed, this is what actually catches a module attempting to
|
||||
collide with a Core-owned id specifically, regardless of that module's own id and regardless of
|
||||
whether the id happens to exactly match an existing declaration.
|
||||
|
||||
Composition itself performs no filesystem, environment, database, network, or module-code
|
||||
operation — it is pure, synchronous, in-memory composition over data a caller already discovered
|
||||
and validated (`core/src/authorization` and `core/src/modules` remain independent of each other in
|
||||
the other direction: authorization never imports module discovery or runtime, only the module
|
||||
integration layer depends on authorization).
|
||||
|
||||
## API startup: discovery, composition, and enforcement
|
||||
|
||||
`apps/api` builds its production permission registry from the complete installed-module inventory
|
||||
every time it starts, in a fixed order, before anything else:
|
||||
|
||||
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
|
||||
platform/Module SDK versions from their own `package.json` files).
|
||||
2. Discover installed modules (`discoverModules`).
|
||||
3. Compose the platform permission registry (`composePlatformPermissionRegistry`).
|
||||
4. Construct the shared PostgreSQL pool.
|
||||
5. Construct Identity and Authorization.
|
||||
6. Create the Hono application, injecting the composed registry.
|
||||
7. Bind the HTTP server.
|
||||
|
||||
`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3.
|
||||
Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server
|
||||
exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the
|
||||
reserved `core.*` namespace, or a duplicate permission declaration — leaves nothing to close: it
|
||||
propagates directly out of `startApi()`, and startup never falls back to `CORE_PERMISSION_REGISTRY`
|
||||
or any Core-only registry. The repository root is resolved from this compiled/source file's own
|
||||
location (two directories up from `apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at
|
||||
identical depth), never `process.cwd()` — a package-scoped test run has `apps/api` as its working
|
||||
directory, which would otherwise point discovery at the wrong `modules/` directory.
|
||||
|
||||
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
|
||||
installation, removal, or dynamic mutation of installed modules or their permissions while the
|
||||
process is running — an installed-module change (add, remove, or edit a manifest) takes effect
|
||||
only on the next API restart. `createApiApp` requires this registry as part of its injected
|
||||
authorization options; `requirePermission` and the explicit-grant routes
|
||||
(`GET/POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke`) consult exactly the same
|
||||
registry Core's own `core.authorization.grants.manage` permission is registered in, so a
|
||||
module-owned permission and Core's own are enforced, and grantable, through the identical
|
||||
mechanism — there is no module-specific enforcement path. Removing a module from the next
|
||||
startup's discovered inventory makes its permission unknown to new authorization checks and new
|
||||
grants (a well-formed but unregistered permission denies exactly as before, including on
|
||||
`POST /api/v1/grants`, which checks the registry explicitly before calling the store); it never
|
||||
deletes, revokes, or otherwise modifies a historical grant or audit row already recorded for that
|
||||
permission. A previously granted, still-unrevoked explicit grant for a now-uninstalled module's
|
||||
permission remains visible through `GET /api/v1/grants` and simply stops being something a
|
||||
`restricted`-baseline authorization check can newly succeed against. Listing and explicit
|
||||
revocation (`POST /api/v1/grants/:grantId/revoke`) are unaffected either way: neither route
|
||||
consults the registry for a grant that already exists — revocation is keyed to the grant's own id
|
||||
and source kind, not to whether its permission is currently registered — so a manager can still
|
||||
revoke a historical grant for a now-unregistered permission.
|
||||
|
||||
Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived
|
||||
grant creation both remain deferred, unrelated slices — this work only makes a module's *declared*
|
||||
permission usable through Core's existing, generic explicit-grant HTTP API and enforcement
|
||||
middleware.
|
||||
|
||||
## Doctor and modules:validate
|
||||
|
||||
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
||||
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
||||
it never re-discovers modules itself. A successful composition reports a pass with a safe
|
||||
declaration count (for example `1 permission registered` with zero installed modules, since Core's
|
||||
own declaration is always present); zero installed modules still composes successfully. If module
|
||||
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
|
||||
pattern; if discovery succeeded but composition failed, schema and migration checks still run
|
||||
normally — they are independent diagnostics that do not depend on the permission registry. A
|
||||
reserved-namespace violation (`ModulePermissionCompositionError`,
|
||||
`MODULE_PERMISSION_RESERVED_NAMESPACE`) and a generic duplicate declaration
|
||||
(`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) are reported as distinct
|
||||
failure details — a reserved `core.*` claim is never described as an ordinary duplicate, even
|
||||
though (see above) a module attempting to exactly collide with a Core-owned id always surfaces as
|
||||
the reserved-namespace failure specifically. Composition requires no database and never imports or
|
||||
executes a module's server entrypoint. `pnpm modules:validate` composes the same registry from its
|
||||
own already-discovered modules for the identical reason: a manifest that is otherwise structurally
|
||||
valid but claims the `core.*` namespace, or duplicates a permission id, now fails validation before
|
||||
that module is considered installable.
|
||||
|
||||
### Two layers, not one: shape versus registration
|
||||
|
||||
@@ -218,7 +339,7 @@ cannot smuggle a server-derived field through a request that otherwise looks wel
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
|
||||
| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. |
|
||||
| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in the running API's composed platform registry (Core's own declarations plus every installed module's — see [API startup](#api-startup-discovery-composition-and-enforcement)). A malformed value is a `400`; a well-formed but unregistered one is a `403`. |
|
||||
| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. |
|
||||
| `expiresAt` | No | An ISO date string if present. |
|
||||
|
||||
@@ -287,7 +408,19 @@ there are or whether the failure happened during startup.
|
||||
## Tests
|
||||
|
||||
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
|
||||
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY` contents.
|
||||
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY`/`CORE_PERMISSION_DECLARATIONS` contents
|
||||
and immutability.
|
||||
- `packages/contracts/tests/authorization.test.ts` — `PermissionDeclaration` shape validation:
|
||||
every baseline, unknown-property/missing-baseline/invalid-baseline/blank-or-oversized-description
|
||||
rejection, malformed permission id rejection.
|
||||
- `packages/contracts/tests/module-manifest.test.ts` — `manifestVersion: 3` acceptance and version
|
||||
2/string-array rejection, per-declaration validation (mirroring the contracts-level cases above),
|
||||
module-namespace enforcement, duplicate permission id at its exact `permissions[index].id` issue
|
||||
path, and default-empty-list isolation across separate parses.
|
||||
- `core/tests/module-permissions.test.ts` — `composePlatformPermissionRegistry`: Core-only and
|
||||
Core-plus-modules composition, deterministic ordering, exact baseline/description preservation,
|
||||
duplicate rejection, reserved `core.*` namespace rejection, unknown-permission denial once a
|
||||
module is absent from the supplied list, and input/output immutability with no I/O.
|
||||
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
|
||||
per-domain database tests).
|
||||
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`
|
||||
@@ -298,13 +431,27 @@ there are or whether the failure happened during startup.
|
||||
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
|
||||
still blocking reuse, and the concurrency scenario described above.
|
||||
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
|
||||
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior.
|
||||
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior;
|
||||
a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/
|
||||
`restricted` declarations identically to Core's own, that the evaluator receives the registry's
|
||||
exact id/baseline, and that an absent module permission denies before principal resolution or
|
||||
evaluator access — the shared mechanism, not a module-specific code path.
|
||||
- `core/tests/doctor.test.ts` / `core/tests/doctor-report.test.ts` — the `modules.permissions`
|
||||
check: passing count (including zero installed modules), reuse of the already-discovered module
|
||||
list (no rediscovery), distinct reserved-namespace vs. generic-duplicate failure detail, an
|
||||
unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing
|
||||
independently after a composition failure, and the exact documented terminal report shape.
|
||||
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
|
||||
and malformed-field rejection, a well-formed but unregistered target permission rejected with
|
||||
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
||||
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
||||
store-error mapping.
|
||||
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above.
|
||||
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above,
|
||||
plus module-permission composition at startup: discovery/composition run before any pool,
|
||||
Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace
|
||||
failure, and a generic duplicate-declaration failure each create zero pools/Better Auth
|
||||
instances/servers and remain distinguishable from one another; zero installed modules still
|
||||
succeeds; and import safety for `platform-permissions.ts`.
|
||||
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
|
||||
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
||||
consulting the grant or platform-owner ports for `public`/`authenticated`.
|
||||
@@ -314,11 +461,21 @@ there are or whether the failure happened during startup.
|
||||
account then managing grants as a non-owner, a well-formed but unregistered target permission
|
||||
rejected with `403` (with no grant or audit row written) even for a caller with full
|
||||
grant-management authority, a create/list/revoke cycle with audit-trail verification (using
|
||||
`core.authorization.grants.manage` itself as the granted permission, since module permission
|
||||
registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection,
|
||||
a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an
|
||||
infrastructure-failure scenario (database dropped out from under a live session) asserting a
|
||||
`503` rather than a false `403` or `200`.
|
||||
`core.authorization.grants.manage` itself as the granted permission), disallowed-field
|
||||
rejection, missing/untrusted-origin rejection, a real relationship-derived grant proven
|
||||
invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database
|
||||
dropped out from under a live session) asserting a `503` rather than a false `403` or `200`.
|
||||
- `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live
|
||||
PostgreSQL) — a real temporary module fixture (structurally valid manifest, a server entrypoint
|
||||
that throws if ever imported) proves the complete chain against a real API process and real
|
||||
Doctor run: Doctor recognizes the installed module and passes permission composition without
|
||||
executing its entrypoint; API startup discovers the fixture and makes its declared permission
|
||||
enforceable; a bootstrapped platform owner creates a real explicit grant for the module's
|
||||
permission with an atomic audit row recorded under its exact declared id; a well-formed
|
||||
permission absent from the composed registry is rejected with no grant or audit write; and,
|
||||
after a clean shutdown and restart with the module absent, the same permission can no longer be
|
||||
newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity
|
||||
HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout.
|
||||
|
||||
Run the focused suites:
|
||||
|
||||
@@ -330,7 +487,10 @@ pnpm test:migrations:integration # requires Docker
|
||||
|
||||
## Next phase
|
||||
|
||||
Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission
|
||||
registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service
|
||||
accounts/API tokens for non-browser automation, and relationship-derived grant creation itself
|
||||
(a roster or similar module operation, not the generic grants API).
|
||||
Deliberately deferred, unrelated slices — see
|
||||
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
|
||||
for the complete list: module HTTP route-registration contracts (a module mounting its own
|
||||
endpoints), a module runtime-context authorization service, relationship-derived grant creation
|
||||
itself (a roster or similar module operation, not the generic grants API), reinstall/reactivation
|
||||
policy for a permission whose owning module was previously removed, roles/groups/teams/tenants, an
|
||||
owner-management HTTP endpoint, and service accounts/API tokens for non-browser automation.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,7 +48,7 @@ without executing module code:
|
||||
|
||||
```json
|
||||
{
|
||||
"manifestVersion": 2,
|
||||
"manifestVersion": 3,
|
||||
"id": "calendar",
|
||||
"name": "Calendar",
|
||||
"version": "0.1.0",
|
||||
@@ -77,7 +77,13 @@ without executing module code:
|
||||
"version": "1.0.0"
|
||||
}
|
||||
],
|
||||
"permissions": ["calendar.events.read"],
|
||||
"permissions": [
|
||||
{
|
||||
"id": "calendar.events.read",
|
||||
"baseline": "restricted",
|
||||
"description": "Read calendar events."
|
||||
}
|
||||
],
|
||||
"storage": {
|
||||
"buckets": [
|
||||
{
|
||||
@@ -99,6 +105,33 @@ has its own exact semantic version because its public contract can evolve indepe
|
||||
module release. Capability dependencies use semantic-version ranges. Optional means the provider
|
||||
may be absent; if it is installed, its capability version must be compatible.
|
||||
|
||||
`manifestVersion: 3` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
|
||||
version 2 manifest, and any other version, fails validation outright rather than being silently
|
||||
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
|
||||
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
|
||||
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
|
||||
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
|
||||
each baseline means to the evaluator), and a required, non-blank `description` up to 240 characters
|
||||
for operators and audit review only — Core's authorization layer never reads or interprets it.
|
||||
Declaring a permission here is necessary but not sufficient for it to be enforceable: the manifest
|
||||
only proves the declaration is well-formed and namespaced correctly, and does not itself register
|
||||
anything with Core's authorization registry (see
|
||||
[Authorization](../development/authorization.md#module-permission-composition) for how Core composes
|
||||
every discovered module's declarations, alongside its own, into the one platform permission
|
||||
registry). A duplicate permission id within one manifest, or a permission id outside the module's
|
||||
own namespace, fails validation the same way a duplicate or misnamed capability id does.
|
||||
|
||||
`apps/api` composes this registry once, at startup, from every module installed at that time (see
|
||||
[Authorization](../development/authorization.md#api-startup-discovery-composition-and-enforcement));
|
||||
installing, removing, or editing a module's manifest takes effect only on the next API restart, not
|
||||
while it is running. Once installed and discovered, a module's declared permission is enforceable
|
||||
and grantable through exactly the same mechanism as any Core-owned permission — the same
|
||||
`requirePermission` middleware and the same `/api/v1/grants` explicit-grant HTTP API a platform
|
||||
owner (or anyone they delegate `core.authorization.grants.manage` to) already uses. Mounting a
|
||||
module's own HTTP routes, and creating relationship-derived grants automatically from a module's
|
||||
own business logic, are both still later work; this only makes a declared permission *usable*
|
||||
through Core's existing, generic surface.
|
||||
|
||||
Server and optional schema entrypoints must be compiled ESM `.js` or `.mjs` files. The optional
|
||||
migration entrypoint is a directory. All entrypoints begin with `./`, remain inside the package,
|
||||
and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { permissionIdSchema } from "./identifiers.js";
|
||||
import type { EkklesiaUserId } from "./identity.js";
|
||||
import { parseEkklesiaUserId } from "./identity.js";
|
||||
|
||||
@@ -51,6 +52,44 @@ export const permissionBaselineSchema = z.enum(["public", "authenticated", "rest
|
||||
|
||||
export type PermissionBaseline = z.infer<typeof permissionBaselineSchema>;
|
||||
|
||||
/**
|
||||
* The one authoritative shape of a *registered* permission declaration — what both Core's
|
||||
* authorization registry (`core/src/authorization/permissions.ts`) and a module manifest's
|
||||
* `permissions` array parse into, so a permission a module declares and a permission Core's
|
||||
* registry enforces are provably the same structure, never two independently maintained
|
||||
* interfaces that could quietly drift apart. `.strict()` rejects any property beyond these three —
|
||||
* there is no room for a module or a future caller to smuggle extra, unvalidated metadata into a
|
||||
* declaration Core treats as trusted.
|
||||
*
|
||||
* This schema says nothing about *registration*: parsing a value into a `PermissionDeclaration`
|
||||
* only proves it is shaped correctly, not that any registry has accepted it. Registration remains
|
||||
* exclusively `PermissionRegistry`'s responsibility (`core/src/authorization/permissions.ts`) —
|
||||
* built by composing exactly the declarations a trusted caller supplies, Core's own plus every
|
||||
* discovered module's.
|
||||
*/
|
||||
export const permissionDeclarationSchema = z
|
||||
.object({
|
||||
id: permissionIdSchema,
|
||||
baseline: permissionBaselineSchema,
|
||||
/** Operator/audit-facing text only. Authorization never reads, parses, or otherwise
|
||||
* interprets this value — changing it can never change what a permission allows. */
|
||||
description: z.string().trim().min(1).max(240),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type PermissionDeclarationInput = z.input<typeof permissionDeclarationSchema>;
|
||||
export type PermissionDeclaration = z.output<typeof permissionDeclarationSchema>;
|
||||
|
||||
export function parsePermissionDeclaration(input: unknown): PermissionDeclaration {
|
||||
return permissionDeclarationSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParsePermissionDeclaration(
|
||||
input: unknown,
|
||||
): z.ZodSafeParseResult<PermissionDeclaration> {
|
||||
return permissionDeclarationSchema.safeParse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguishes a grant an operator/system created directly (`explicit`) from one a module
|
||||
* created as a side effect of its own business operation, attributed to some source the module
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
||||
import {
|
||||
capabilityIdSchema,
|
||||
logicalBucketIdSchema,
|
||||
moduleIdSchema,
|
||||
packageRelativePathSchema,
|
||||
permissionIdSchema,
|
||||
} from "./identifiers.js";
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
@@ -89,7 +89,7 @@ function reportDuplicates(
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
manifestVersion: z.literal(2),
|
||||
manifestVersion: z.literal(3),
|
||||
id: moduleIdSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500).optional(),
|
||||
@@ -102,7 +102,7 @@ export const moduleManifestSchema = z
|
||||
.strict(),
|
||||
dependencies: dependenciesSchema,
|
||||
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||
permissions: z.array(permissionIdSchema).default(() => []),
|
||||
permissions: z.array(permissionDeclarationSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
entrypoints: entrypointsSchema,
|
||||
})
|
||||
@@ -114,7 +114,12 @@ export const moduleManifestSchema = z
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
reportDuplicates(manifest.permissions, ["permissions"], context);
|
||||
reportDuplicates(
|
||||
manifest.permissions.map((permission) => permission.id),
|
||||
["permissions"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||
["storage", "buckets"],
|
||||
@@ -133,11 +138,11 @@ export const moduleManifestSchema = z
|
||||
});
|
||||
|
||||
manifest.permissions.forEach((permission, index) => {
|
||||
if (!permission.startsWith(`${manifest.id}.`)) {
|
||||
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["permissions", index],
|
||||
path: ["permissions", index, "id"],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -197,3 +202,7 @@ export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
||||
/** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's
|
||||
* authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for
|
||||
* discoverability alongside the manifest's other per-field declaration types. */
|
||||
export type ModulePermissionDeclaration = PermissionDeclaration;
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
isUserActor,
|
||||
parseAuthorizationScope,
|
||||
parseGrantId,
|
||||
parsePermissionDeclaration,
|
||||
parsePermissionId,
|
||||
type PermissionDeclaration,
|
||||
type PermissionId,
|
||||
safeParseAuthorizationScope,
|
||||
safeParseGrantId,
|
||||
safeParsePermissionDeclaration,
|
||||
safeParsePermissionId,
|
||||
SYSTEM_ACTOR,
|
||||
} from "../src/index.js";
|
||||
@@ -50,6 +53,87 @@ describe("PermissionId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PermissionDeclaration", () => {
|
||||
function validDeclaration(): Record<string, unknown> {
|
||||
return {
|
||||
id: "calendar.events.read",
|
||||
baseline: "restricted",
|
||||
description: "Read calendar events.",
|
||||
};
|
||||
}
|
||||
|
||||
it.each([
|
||||
"public",
|
||||
"authenticated",
|
||||
"restricted",
|
||||
] as const)("parses a well-formed declaration with baseline %s", (baseline) => {
|
||||
const declaration = parsePermissionDeclaration({ ...validDeclaration(), baseline });
|
||||
expect(declaration).toEqual({
|
||||
id: "calendar.events.read",
|
||||
baseline,
|
||||
description: "Read calendar events.",
|
||||
});
|
||||
});
|
||||
|
||||
it("is exactly three fields — this is the one shape both a module manifest and Core's registry parse into", () => {
|
||||
const declaration: PermissionDeclaration = parsePermissionDeclaration(validDeclaration());
|
||||
expect(Object.keys(declaration).sort()).toEqual(["baseline", "description", "id"]);
|
||||
});
|
||||
|
||||
it("rejects an unknown declaration property", () => {
|
||||
const result = safeParsePermissionDeclaration({
|
||||
...validDeclaration(),
|
||||
scope: "calendar-event:123",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing baseline", () => {
|
||||
const { baseline: _baseline, ...withoutBaseline } = validDeclaration();
|
||||
expect(safeParsePermissionDeclaration(withoutBaseline).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an invalid baseline", () => {
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), baseline: "superuser" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing, empty, or whitespace-only description", () => {
|
||||
const { description: _description, ...withoutDescription } = validDeclaration();
|
||||
expect(safeParsePermissionDeclaration(withoutDescription).success).toBe(false);
|
||||
expect(safeParsePermissionDeclaration({ ...validDeclaration(), description: "" }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), description: " " }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a description beyond the fixed maximum length", () => {
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(240) })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), description: "x".repeat(241) })
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed permission id", () => {
|
||||
expect(
|
||||
safeParsePermissionDeclaration({ ...validDeclaration(), id: "not-namespaced" }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never registers or evaluates anything — parsing only proves the value is well-formed", () => {
|
||||
// Nothing here consults a registry: `parsePermissionDeclaration` succeeding says nothing
|
||||
// about whether `calendar.events.read` is actually registered anywhere.
|
||||
expect(() => parsePermissionDeclaration(validDeclaration())).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GrantId", () => {
|
||||
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
|
||||
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
MODULE_LIFECYCLE_STAGES,
|
||||
packageRelativePathSchema,
|
||||
parseModuleManifest,
|
||||
parsePermissionId,
|
||||
safeParseModuleManifest,
|
||||
} from "../src/index.js";
|
||||
|
||||
function validManifest(): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
manifestVersion: 3,
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
version: "0.1.0",
|
||||
@@ -18,7 +19,13 @@ function validManifest(): Record<string, unknown> {
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
capabilities: [{ id: "calendar.events", version: "2.3.0" }],
|
||||
permissions: ["calendar.events.read"],
|
||||
permissions: [
|
||||
{
|
||||
id: "calendar.events.read",
|
||||
baseline: "restricted",
|
||||
description: "Read calendar events.",
|
||||
},
|
||||
],
|
||||
storage: {
|
||||
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
|
||||
},
|
||||
@@ -45,16 +52,23 @@ describe("module manifest contract", () => {
|
||||
it("creates isolated default collections for every parsed manifest", () => {
|
||||
const input = validManifest();
|
||||
delete input.storage;
|
||||
delete input.permissions;
|
||||
const first = parseModuleManifest(input);
|
||||
const second = parseModuleManifest(input);
|
||||
|
||||
first.dependencies.modules.required.people = "^0.1.0";
|
||||
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
|
||||
first.storage.buckets.push({ id: "exports" });
|
||||
first.permissions.push({
|
||||
id: parsePermissionId("calendar.events.write"),
|
||||
baseline: "restricted",
|
||||
description: "Write calendar events.",
|
||||
});
|
||||
|
||||
expect(second.dependencies.modules.required).toEqual({});
|
||||
expect(second.dependencies.capabilities.required).toEqual({});
|
||||
expect(second.storage.buckets).toEqual([]);
|
||||
expect(second.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects unknown and presentation-oriented manifest properties", () => {
|
||||
@@ -68,14 +82,16 @@ describe("module manifest contract", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
capabilities: [{ id: "media.items", version: "1.0.0" }],
|
||||
permissions: ["media.items.read"],
|
||||
permissions: [
|
||||
{ id: "media.items.read", baseline: "restricted", description: "Read media items." },
|
||||
],
|
||||
};
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
|
||||
expect.arrayContaining(["capabilities.0.id", "permissions.0"]),
|
||||
expect.arrayContaining(["capabilities.0.id", "permissions.0.id"]),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -107,6 +123,32 @@ describe("module manifest contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a duplicate permission id at its exact issue path", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{
|
||||
id: "calendar.events.read",
|
||||
baseline: "restricted",
|
||||
description: "Read calendar events.",
|
||||
},
|
||||
{
|
||||
id: "calendar.events.read",
|
||||
baseline: "authenticated",
|
||||
description: "Read calendar events, again.",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
|
||||
"permissions.1.id",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects module and capability self-dependencies", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
@@ -151,6 +193,113 @@ describe("module manifest contract", () => {
|
||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a version 2 manifest rather than silently interpreting it as version 3", () => {
|
||||
// manifestVersion 2's own permissions shape (a bare string array) is also what version 3
|
||||
// must reject — this manifest is invalid twice over, deliberately, since a real version 2
|
||||
// manifest is exactly this shape.
|
||||
const version2Manifest = {
|
||||
...validManifest(),
|
||||
manifestVersion: 2,
|
||||
permissions: ["calendar.events.read"],
|
||||
};
|
||||
|
||||
expect(safeParseModuleManifest(version2Manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a bare permission-id string array instead of complete declarations", () => {
|
||||
const manifest = { ...validManifest(), permissions: ["calendar.events.read"] };
|
||||
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to an empty permission list when omitted", () => {
|
||||
const input = validManifest();
|
||||
delete input.permissions;
|
||||
|
||||
const manifest = parseModuleManifest(input);
|
||||
expect(manifest.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"public",
|
||||
"authenticated",
|
||||
"restricted",
|
||||
] as const)("accepts a %s permission declaration and preserves its exact baseline", (baseline) => {
|
||||
const manifest = parseModuleManifest({
|
||||
...validManifest(),
|
||||
permissions: [{ id: "calendar.events.read", baseline, description: "Read events." }],
|
||||
});
|
||||
expect(manifest.permissions).toEqual([
|
||||
{ id: "calendar.events.read", baseline, description: "Read events." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with a missing baseline", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [{ id: "calendar.events.read", description: "Read events." }],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with an invalid baseline", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{ id: "calendar.events.read", baseline: "superuser", description: "Read events." },
|
||||
],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with a missing or blank description", () => {
|
||||
const missingDescription = {
|
||||
...validManifest(),
|
||||
permissions: [{ id: "calendar.events.read", baseline: "restricted" }],
|
||||
};
|
||||
const blankDescription = {
|
||||
...validManifest(),
|
||||
permissions: [{ id: "calendar.events.read", baseline: "restricted", description: " " }],
|
||||
};
|
||||
expect(safeParseModuleManifest(missingDescription).success).toBe(false);
|
||||
expect(safeParseModuleManifest(blankDescription).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with an oversized description", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{ id: "calendar.events.read", baseline: "restricted", description: "x".repeat(241) },
|
||||
],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a permission declaration with an unknown property", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{
|
||||
id: "calendar.events.read",
|
||||
baseline: "restricted",
|
||||
description: "Read events.",
|
||||
scope: "calendar-event:123",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed permission id", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
permissions: [
|
||||
{ id: "Calendar.Events.Read", baseline: "restricted", description: "Read events." },
|
||||
],
|
||||
};
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"../outside.ts",
|
||||
"/absolute.ts",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifestVersion": 2,
|
||||
"manifestVersion": 3,
|
||||
"id": "example",
|
||||
"name": "Example Module",
|
||||
"description": "A minimal backend-only Ekklesia module template.",
|
||||
@@ -14,7 +14,13 @@
|
||||
"version": "1.0.0"
|
||||
}
|
||||
],
|
||||
"permissions": ["example.records.read"],
|
||||
"permissions": [
|
||||
{
|
||||
"id": "example.records.read",
|
||||
"baseline": "restricted",
|
||||
"description": "Read example records."
|
||||
}
|
||||
],
|
||||
"storage": {
|
||||
"buckets": [
|
||||
{
|
||||
|
||||
@@ -20,6 +20,13 @@ describe("module template", () => {
|
||||
|
||||
expect(manifest.id).toBe("example");
|
||||
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
|
||||
expect(manifest.permissions).toEqual([
|
||||
{
|
||||
id: "example.records.read",
|
||||
baseline: "restricted",
|
||||
description: "Read example records.",
|
||||
},
|
||||
]);
|
||||
expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]);
|
||||
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
||||
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
||||
|
||||
Reference in New Issue
Block a user