feat: integrate module authorization #11
@@ -7,7 +7,8 @@ surfaces.
|
|||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
|
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,
|
public module contracts, independently versioned capabilities, static module manifests,
|
||||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
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
|
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
|
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`
|
`/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
|
on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, and service
|
||||||
accounts/API tokens, and module-permission registration remain later work. See
|
accounts/API tokens remain later work. See [Authorization](docs/development/authorization.md) and
|
||||||
[Authorization](docs/development/authorization.md) and
|
|
||||||
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
[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
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||||
@@ -89,6 +105,8 @@ Modules
|
|||||||
0 modules installed
|
0 modules installed
|
||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
|
✓ Permission registry composed
|
||||||
|
1 permission registered
|
||||||
|
|
||||||
Data definitions
|
Data definitions
|
||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
|
|||||||
+9
-5
@@ -1,8 +1,8 @@
|
|||||||
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
|
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||||
import {
|
import type {
|
||||||
type AuthorizationEvaluator,
|
AuthorizationEvaluator,
|
||||||
type AuthorizationStore,
|
AuthorizationStore,
|
||||||
CORE_PERMISSION_REGISTRY,
|
PermissionRegistry,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
@@ -12,6 +12,10 @@ import { createGrantsRoutes } from "./grants-routes.js";
|
|||||||
|
|
||||||
export interface CreateApiAppAuthorizationOptions {
|
export interface CreateApiAppAuthorizationOptions {
|
||||||
readonly evaluator: AuthorizationEvaluator;
|
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;
|
readonly store: AuthorizationStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +84,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
"/",
|
"/",
|
||||||
createGrantsRoutes({
|
createGrantsRoutes({
|
||||||
evaluator: options.authorization.evaluator,
|
evaluator: options.authorization.evaluator,
|
||||||
registry: CORE_PERMISSION_REGISTRY,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
store: options.authorization.store,
|
store: options.authorization.store,
|
||||||
trustedOrigins: allowedOrigins,
|
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
|
* Builds a Hono middleware that enforces `permissionId`, resolved exclusively from the injected,
|
||||||
* trusted, server-side permission registry — a route never supplies its own baseline, and no
|
* trusted, server-side `PermissionRegistry` — in production, the complete platform registry
|
||||||
* request input can influence which baseline is checked. The principal is resolved exclusively
|
* `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`
|
* 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
|
* 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
|
* 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,
|
createAuthorizationStore,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||||
|
import type { DiscoveredModule } from "@ekklesia/core/modules";
|
||||||
import { serve, type ServerType } from "@hono/node-server";
|
import { serve, type ServerType } from "@hono/node-server";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { createApiApp } from "./app.js";
|
import { createApiApp } from "./app.js";
|
||||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||||
|
import { composeStartupPermissionRegistry } from "./platform-permissions.js";
|
||||||
|
|
||||||
const connectionTimeoutMs = 10_000;
|
const connectionTimeoutMs = 10_000;
|
||||||
const operationTimeoutMs = 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
|
/** Test-only escape hatch: never set by the executable entry point. See
|
||||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
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> {
|
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
|
* Explicit runtime composition, in a fixed order: discover every installed module's manifest and
|
||||||
* authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity
|
* compose the one immutable platform permission registry from Core's own declarations plus every
|
||||||
* runtime, the Core authorization store and evaluator, and the Hono application, then starts the
|
* module's (`./platform-permissions.js`); only then construct the one shared PostgreSQL pool
|
||||||
* Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry
|
* (identity and authorization each get a typed Drizzle view over it — see `./database.js`), the
|
||||||
* point and tests can stop the server and close the shared pool deterministically. Never applies
|
* Core identity runtime, the Core authorization store and evaluator, and the Hono application;
|
||||||
* migrations or generates schema — the database must already be migrated through
|
* finally start the Node HTTP server. Permission composition happens before any pool, Better
|
||||||
* `pnpm migrations:apply`.
|
* 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(
|
export async function startApi(
|
||||||
config: ApiRuntimeConfig,
|
config: ApiRuntimeConfig,
|
||||||
options: StartApiOptions = {},
|
options: StartApiOptions = {},
|
||||||
): Promise<RunningApi> {
|
): Promise<RunningApi> {
|
||||||
|
const permissionRegistry = await composeStartupPermissionRegistry({
|
||||||
|
...(options.modulesDirectoryOverride === undefined
|
||||||
|
? {}
|
||||||
|
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||||
|
...(options.discoveredModulesOverride === undefined
|
||||||
|
? {}
|
||||||
|
: { discoveredModulesOverride: options.discoveredModulesOverride }),
|
||||||
|
});
|
||||||
|
|
||||||
const databaseHandle = createApiDatabase(config.databaseUrl, {
|
const databaseHandle = createApiDatabase(config.databaseUrl, {
|
||||||
connectionTimeoutMs,
|
connectionTimeoutMs,
|
||||||
operationTimeoutMs,
|
operationTimeoutMs,
|
||||||
@@ -91,7 +115,7 @@ export async function startApi(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: { evaluator, store: authorizationStore },
|
authorization: { evaluator, registry: permissionRegistry, store: authorizationStore },
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: config.identity.trustedOrigins,
|
trustedOrigins: config.identity.trustedOrigins,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
createAuthorizationDrizzleDatabase,
|
createAuthorizationDrizzleDatabase,
|
||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
} from "@ekklesia/core/authorization";
|
} from "@ekklesia/core/authorization";
|
||||||
|
import { diagnoseEkklesia } from "@ekklesia/core/diagnostics";
|
||||||
import {
|
import {
|
||||||
applyModuleMigrations,
|
applyModuleMigrations,
|
||||||
createPostgresMigrationDatabase,
|
createPostgresMigrationDatabase,
|
||||||
discoverAllMigrations,
|
discoverAllMigrations,
|
||||||
} from "@ekklesia/core/migrations";
|
} 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 { tmpdir } from "node:os";
|
||||||
import { Client, type QueryResultRow } from "pg";
|
import { Client, type QueryResultRow } from "pg";
|
||||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
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 () => {
|
afterAll(async () => {
|
||||||
for (const databaseName of [...trackedDatabases]) {
|
for (const databaseName of [...trackedDatabases]) {
|
||||||
await dropDatabase(databaseName).catch(() => undefined);
|
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";
|
const identityBaseUrl = "https://identity.example.test";
|
||||||
@@ -216,7 +267,10 @@ function apiOrigin(running: RunningApi): string {
|
|||||||
return `http://127.0.0.1:${address.port}`;
|
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(
|
const config = createApiRuntimeConfig(
|
||||||
{
|
{
|
||||||
databaseUrl: url,
|
databaseUrl: url,
|
||||||
@@ -230,10 +284,15 @@ async function startMigratedApi(url: string): Promise<RunningApi> {
|
|||||||
},
|
},
|
||||||
{ environment: "production" },
|
{ environment: "production" },
|
||||||
);
|
);
|
||||||
// A narrow, explicit test-only override: production composition (server.ts's `main`) never
|
// Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets
|
||||||
// sets this. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would
|
// either. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would otherwise
|
||||||
// otherwise reject this suite's many sequential requests.
|
// reject this suite's many sequential requests; `modulesDirectoryOverride` lets a scenario point
|
||||||
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
// 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 {
|
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 ownerEmail = "[email protected]";
|
||||||
const managerEmail = "[email protected]";
|
const managerEmail = "[email protected]";
|
||||||
const password = "correct horse battery staple";
|
const password = "correct horse battery staple";
|
||||||
// Module permission registration does not exist yet, so `core.authorization.grants.manage` —
|
// This scenario intentionally proves out the explicit-grant lifecycle (create, list, use,
|
||||||
// the one permission `CORE_PERMISSION_REGISTRY` actually declares — stands in for "some
|
// revoke) against Core's own grant-management permission rather than a module's — a module
|
||||||
// registered permission" throughout this scenario's explicit-grant lifecycle.
|
// 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";
|
const testPermission = "core.authorization.grants.manage";
|
||||||
// Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the
|
// 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.
|
// 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);
|
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 { 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 type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
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
|
/** 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: {
|
function fakeAuthorization(overrides: {
|
||||||
readonly evaluator?: Partial<AuthorizationEvaluator>;
|
readonly evaluator?: Partial<AuthorizationEvaluator>;
|
||||||
|
readonly registry?: PermissionRegistry;
|
||||||
readonly store?: Partial<AuthorizationStore>;
|
readonly store?: Partial<AuthorizationStore>;
|
||||||
}): CreateApiAppAuthorizationOptions {
|
}): CreateApiAppAuthorizationOptions {
|
||||||
const evaluator: AuthorizationEvaluator = {
|
const evaluator: AuthorizationEvaluator = {
|
||||||
@@ -36,7 +45,7 @@ function fakeAuthorization(overrides: {
|
|||||||
revokePlatformOwner: vi.fn(),
|
revokePlatformOwner: vi.fn(),
|
||||||
...overrides.store,
|
...overrides.store,
|
||||||
};
|
};
|
||||||
return { evaluator, store };
|
return { evaluator, registry: overrides.registry ?? CORE_PERMISSION_REGISTRY, store };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Ekklesia API Hono application", () => {
|
describe("Ekklesia API Hono application", () => {
|
||||||
@@ -253,6 +262,32 @@ describe("Ekklesia API Hono application", () => {
|
|||||||
expect(evaluatorAuthorize).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||||
const app = createApiApp({
|
const app = createApiApp({
|
||||||
authorization: fakeAuthorization({}),
|
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", () => {
|
describe("getAuthorizedPrincipal", () => {
|
||||||
it("throws when called on a route requirePermission never protected", async () => {
|
it("throws when called on a route requirePermission never protected", async () => {
|
||||||
const { app } = buildTestApp();
|
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.
|
// (or, for listing, still return) an unregistered permission id rather than assuming registration.
|
||||||
const permission = parsePermissionId("calendar.events.read");
|
const permission = parsePermissionId("calendar.events.read");
|
||||||
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
|
// 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
|
// `store.createGrant`/`store.revokeGrant` can do so. This suite exercises `createGrantsRoutes` in
|
||||||
// registration, which does not exist yet.
|
// 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 registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
|
||||||
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
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 poolConstructor = vi.hoisted(() => vi.fn());
|
||||||
const betterAuthMock = 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("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||||
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
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 { createApiRuntimeConfig } from "../src/config.js";
|
||||||
import { installShutdownSignalHandlers, startApi } from "../src/server.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`
|
/** 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;
|
* 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). */
|
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
|
||||||
@@ -285,3 +373,148 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
|||||||
restore();
|
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 {
|
import {
|
||||||
type PermissionBaseline,
|
type PermissionDeclaration,
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
safeParsePermissionId,
|
safeParsePermissionId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A registered permission declaration: the trusted pairing of a permission identifier with its
|
* Core's registry consumes the one shared `PermissionDeclaration` shape
|
||||||
* one baseline. This is what distinguishes a *registered* permission from a merely well-formed
|
* (`packages/contracts/src/authorization.ts`) rather than declaring its own interface — a module
|
||||||
* `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an
|
* manifest's `permissions` array parses into the exact same type, so a declaration a module ships
|
||||||
* explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller
|
* and a declaration Core registers are provably interchangeable. Re-exported here so existing
|
||||||
* (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that
|
* `@ekklesia/core/authorization` consumers keep importing it from this package.
|
||||||
* baseline is never client-supplied or guessed.
|
|
||||||
*/
|
*/
|
||||||
export interface PermissionDeclaration {
|
export type { PermissionDeclaration, PermissionDeclarationInput } from "@ekklesia/contracts";
|
||||||
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 interface PermissionRegistry {
|
export interface PermissionRegistry {
|
||||||
readonly declarations: readonly PermissionDeclaration[];
|
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
|
* 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
|
* duplicate permission id outright at composition time. Deliberately the smallest mechanism that
|
||||||
* distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no
|
* 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
|
* network or filesystem I/O. The module integration layer's composer
|
||||||
* declarations into a combined list and still call this same constructor, inheriting duplicate
|
* (`composePlatformPermissionRegistry`, `core/src/modules/permissions.ts`) merges Core's own
|
||||||
* detection for free instead of reimplementing it.
|
* 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(
|
export function createPermissionRegistry(
|
||||||
declarations: readonly PermissionDeclaration[],
|
declarations: readonly PermissionDeclaration[],
|
||||||
@@ -89,7 +83,14 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
|||||||
"core.authorization.grants.manage",
|
"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({
|
Object.freeze({
|
||||||
baseline: "restricted" as const,
|
baseline: "restricted" as const,
|
||||||
description: "Create, list, and revoke another user's explicit authorization grants.",
|
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
|
* Core's own permission registry, containing exactly the Core-owned permissions declared above.
|
||||||
* enforcement consults so an HTTP route can never supply its own baseline. Contains exactly the
|
* Production `apps/api` does not consult this constant directly: it enforces requests against the
|
||||||
* one Core-owned permission this slice needs; a future module registration mechanism is
|
* composed platform registry (`composePlatformPermissionRegistry`,
|
||||||
* deliberately out of scope here and will compose its own declarations through
|
* `core/src/modules/permissions.ts`), which merges these same declarations with every installed
|
||||||
* `createPermissionRegistry` rather than through this constant.
|
* 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(
|
export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry(
|
||||||
CORE_PERMISSION_DECLARATIONS,
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { PermissionRegistryError } from "../authorization/index.js";
|
||||||
import {
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
ModuleDiscoveryError,
|
ModuleDiscoveryError,
|
||||||
|
ModulePermissionCompositionError,
|
||||||
ModuleSchemaDiscoveryError,
|
ModuleSchemaDiscoveryError,
|
||||||
} from "../modules/index.js";
|
} from "../modules/index.js";
|
||||||
|
|
||||||
@@ -31,6 +34,12 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
const modules = schemaAggregate.discoveredModules;
|
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) {
|
if (modules.length === 0) {
|
||||||
console.log("Module validation passed (0 modules installed).");
|
console.log("Module validation passed (0 modules installed).");
|
||||||
return;
|
return;
|
||||||
@@ -57,6 +66,10 @@ main().catch((error: unknown) => {
|
|||||||
const issuePath = issue.path ? ` (${issue.path})` : "";
|
const issuePath = issue.path ? ` (${issue.path})` : "";
|
||||||
console.error(`[${issue.code}] ${issue.moduleId}${issuePath}: ${issue.message}`);
|
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 {
|
} else {
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
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([
|
const sections: readonly DoctorSection[] = Object.freeze([
|
||||||
section("Platform", ["platform.runtime"]),
|
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("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||||
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
|
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
|
||||||
section("Identity", ["identity.configuration"]),
|
section("Identity", ["identity.configuration"]),
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import path from "node:path";
|
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 { getTableName } from "drizzle-orm";
|
||||||
|
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
||||||
import {
|
import {
|
||||||
type CreateIdentityConfigOptions,
|
type CreateIdentityConfigOptions,
|
||||||
createIdentityConfig,
|
createIdentityConfig,
|
||||||
@@ -18,9 +24,11 @@ import {
|
|||||||
planModuleMigrations,
|
planModuleMigrations,
|
||||||
} from "../migrations/index.js";
|
} from "../migrations/index.js";
|
||||||
import {
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModuleSchemas,
|
discoverModuleSchemas,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
ModulePermissionCompositionError,
|
||||||
type ModuleDiscoveryOptions,
|
type ModuleDiscoveryOptions,
|
||||||
type ModuleMigrationAggregate,
|
type ModuleMigrationAggregate,
|
||||||
type ModuleSchemaAggregate,
|
type ModuleSchemaAggregate,
|
||||||
@@ -30,6 +38,7 @@ export type DoctorCheckId =
|
|||||||
| "platform.runtime"
|
| "platform.runtime"
|
||||||
| "modules.discovery"
|
| "modules.discovery"
|
||||||
| "modules.dependencies"
|
| "modules.dependencies"
|
||||||
|
| "modules.permissions"
|
||||||
| "schemas.discovery"
|
| "schemas.discovery"
|
||||||
| "migrations.discovery"
|
| "migrations.discovery"
|
||||||
| "postgresql.connection"
|
| "postgresql.connection"
|
||||||
@@ -68,6 +77,9 @@ export interface DoctorOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DoctorDependencies {
|
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: (
|
readonly createMigrationDatabase: (
|
||||||
connectionString: string,
|
connectionString: string,
|
||||||
options: PostgresMigrationDatabaseOptions,
|
options: PostgresMigrationDatabaseOptions,
|
||||||
@@ -101,6 +113,7 @@ async function discoverAllMigrationsForRuntime(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const productionDependencies: DoctorDependencies = Object.freeze({
|
const productionDependencies: DoctorDependencies = Object.freeze({
|
||||||
|
composePermissions: composePlatformPermissionRegistry,
|
||||||
createMigrationDatabase: createPostgresMigrationDatabase,
|
createMigrationDatabase: createPostgresMigrationDatabase,
|
||||||
discoverMigrations: discoverAllMigrationsForRuntime,
|
discoverMigrations: discoverAllMigrationsForRuntime,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
@@ -163,6 +176,57 @@ function safeMigrationReference(identity: string, checksum: string): string {
|
|||||||
return `${identity} · ${checksum.slice(0, 12)}`;
|
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 {
|
function moduleDiscoveryFailure(error: unknown): DoctorCheck {
|
||||||
const details =
|
const details =
|
||||||
error instanceof Error && error.name === "ModuleDiscoveryError"
|
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 schemaCheck: DoctorCheck;
|
||||||
let migrationCheck: DoctorCheck;
|
let migrationCheck: DoctorCheck;
|
||||||
let migrations: ModuleMigrationAggregate | undefined;
|
let migrations: ModuleMigrationAggregate | undefined;
|
||||||
@@ -466,6 +557,7 @@ export async function diagnoseEkklesia(
|
|||||||
runtimeCheck,
|
runtimeCheck,
|
||||||
moduleCheck,
|
moduleCheck,
|
||||||
dependencyCheck,
|
dependencyCheck,
|
||||||
|
permissionsCheck,
|
||||||
schemaCheck,
|
schemaCheck,
|
||||||
migrationCheck,
|
migrationCheck,
|
||||||
postgresConnectionCheck,
|
postgresConnectionCheck,
|
||||||
|
|||||||
@@ -47,7 +47,13 @@ export interface ResolvedModuleEntrypoints {
|
|||||||
readonly jobs?: string;
|
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 (...arguments_: never[]) => unknown
|
||||||
? T
|
? T
|
||||||
: T extends readonly (infer Item)[]
|
: T extends readonly (infer Item)[]
|
||||||
? readonly DeepReadonly<Item>[]
|
? readonly DeepReadonly<Item>[]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./discovery.js";
|
export * from "./discovery.js";
|
||||||
export * from "./migration-discovery.js";
|
export * from "./migration-discovery.js";
|
||||||
|
export * from "./permissions.js";
|
||||||
export * from "./runtime.js";
|
export * from "./runtime.js";
|
||||||
export * from "./schema-discovery.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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||||
|
CORE_PERMISSION_DECLARATIONS,
|
||||||
CORE_PERMISSION_REGISTRY,
|
CORE_PERMISSION_REGISTRY,
|
||||||
createPermissionRegistry,
|
createPermissionRegistry,
|
||||||
PermissionRegistryError,
|
PermissionRegistryError,
|
||||||
@@ -92,3 +93,21 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
|||||||
expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1);
|
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(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "../src/diagnostics/index.js";
|
} from "../src/diagnostics/index.js";
|
||||||
import type { MigrationDatabase } from "../src/migrations/index.js";
|
import type { MigrationDatabase } from "../src/migrations/index.js";
|
||||||
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
|
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
|
||||||
|
import { composePlatformPermissionRegistry } from "../src/modules/permissions.js";
|
||||||
|
|
||||||
const originalNoColor = process.env.NO_COLOR;
|
const originalNoColor = process.env.NO_COLOR;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ function zeroMigrationAggregate(): ModuleMigrationAggregate {
|
|||||||
|
|
||||||
function noConfigurationDependencies(): DoctorDependencies {
|
function noConfigurationDependencies(): DoctorDependencies {
|
||||||
return {
|
return {
|
||||||
|
composePermissions: composePlatformPermissionRegistry,
|
||||||
createMigrationDatabase: () => {
|
createMigrationDatabase: () => {
|
||||||
throw new Error("database must not be constructed");
|
throw new Error("database must not be constructed");
|
||||||
},
|
},
|
||||||
@@ -75,6 +77,8 @@ Modules
|
|||||||
0 modules installed
|
0 modules installed
|
||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
|
✓ Permission registry composed
|
||||||
|
1 permission registered
|
||||||
|
|
||||||
Data definitions
|
Data definitions
|
||||||
✓ Schemas validated
|
✓ 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 { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createPermissionRegistry, PermissionRegistryError } from "../src/authorization/index.js";
|
||||||
import {
|
import {
|
||||||
type DoctorCheck,
|
type DoctorCheck,
|
||||||
type DoctorDependencies,
|
type DoctorDependencies,
|
||||||
@@ -21,6 +22,7 @@ import type {
|
|||||||
ModuleMigrationAggregate,
|
ModuleMigrationAggregate,
|
||||||
ModuleSchemaAggregate,
|
ModuleSchemaAggregate,
|
||||||
} from "../src/modules/index.js";
|
} from "../src/modules/index.js";
|
||||||
|
import { ModulePermissionCompositionError } from "../src/modules/permissions.js";
|
||||||
|
|
||||||
const checksumA = "a".repeat(64);
|
const checksumA = "a".repeat(64);
|
||||||
const checksumB = "b".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" },
|
entrypoints: { server: "./dist/server.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
storage: { buckets: [] },
|
storage: { buckets: [] },
|
||||||
@@ -125,6 +127,11 @@ function plan(entries: MigrationPlanResult["entries"] = []): MigrationPlanResult
|
|||||||
|
|
||||||
function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies {
|
function dependencies(overrides: Partial<DoctorDependencies> = {}): DoctorDependencies {
|
||||||
return {
|
return {
|
||||||
|
composePermissions: vi.fn((modules: readonly DiscoveredModule[]) =>
|
||||||
|
createPermissionRegistry(
|
||||||
|
modules.flatMap((discovered) => [...discovered.manifest.permissions]),
|
||||||
|
),
|
||||||
|
),
|
||||||
createMigrationDatabase: vi.fn(() => emptyDatabase()),
|
createMigrationDatabase: vi.fn(() => emptyDatabase()),
|
||||||
discoverMigrations: vi.fn(async () => migrationAggregate()),
|
discoverMigrations: vi.fn(async () => migrationAggregate()),
|
||||||
discoverModules: vi.fn(async () => Object.freeze([])),
|
discoverModules: vi.fn(async () => Object.freeze([])),
|
||||||
@@ -196,6 +203,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
"platform.runtime",
|
"platform.runtime",
|
||||||
"modules.discovery",
|
"modules.discovery",
|
||||||
"modules.dependencies",
|
"modules.dependencies",
|
||||||
|
"modules.permissions",
|
||||||
"schemas.discovery",
|
"schemas.discovery",
|
||||||
"migrations.discovery",
|
"migrations.discovery",
|
||||||
"postgresql.connection",
|
"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.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.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([
|
expect(check(report, "migrations.discovery").details).toEqual([
|
||||||
"1 migration · checksums valid",
|
"1 migration · checksums valid",
|
||||||
"people:0000_initial · aaaaaaaaaaaa",
|
"people:0000_initial · aaaaaaaaaaaa",
|
||||||
@@ -369,6 +381,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
expect(check(report, "modules.discovery").status).toBe("failure");
|
expect(check(report, "modules.discovery").status).toBe("failure");
|
||||||
for (const id of [
|
for (const id of [
|
||||||
"modules.dependencies",
|
"modules.dependencies",
|
||||||
|
"modules.permissions",
|
||||||
"schemas.discovery",
|
"schemas.discovery",
|
||||||
"migrations.discovery",
|
"migrations.discovery",
|
||||||
"postgresql.connection",
|
"postgresql.connection",
|
||||||
@@ -385,6 +398,114 @@ describe("Ekklesia Doctor diagnostics", () => {
|
|||||||
expect(JSON.stringify(report)).not.toContain("password");
|
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 () => {
|
it("continues independent migration and PostgreSQL checks after schema failure", async () => {
|
||||||
const planMigrations = vi.fn(async () => plan());
|
const planMigrations = vi.fn(async () => plan());
|
||||||
const report = await diagnoseEkklesia(
|
const report = await diagnoseEkklesia(
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function manifestFor(id: string): Record<string, unknown> {
|
function manifestFor(id: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -40,7 +40,9 @@ function manifestFor(id: string): Record<string, unknown> {
|
|||||||
moduleSdk: "^0.1.0",
|
moduleSdk: "^0.1.0",
|
||||||
},
|
},
|
||||||
capabilities: [{ id: `${id}.records`, version: "1.0.0" }],
|
capabilities: [{ id: `${id}.records`, version: "1.0.0" }],
|
||||||
permissions: [`${id}.records.read`],
|
permissions: [
|
||||||
|
{ id: `${id}.records.read`, baseline: "restricted", description: "Read records." },
|
||||||
|
],
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
},
|
},
|
||||||
@@ -151,6 +153,16 @@ describe("module discovery", () => {
|
|||||||
expect(Object.isFrozen(modules[1]?.manifest)).toBe(true);
|
expect(Object.isFrozen(modules[1]?.manifest)).toBe(true);
|
||||||
const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>;
|
const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>;
|
||||||
expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError);
|
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 () => {
|
it("aggregates malformed packages and manifests without partial discovery", async () => {
|
||||||
@@ -215,7 +227,9 @@ describe("module discovery", () => {
|
|||||||
...manifestFor("one"),
|
...manifestFor("one"),
|
||||||
id: "shared",
|
id: "shared",
|
||||||
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||||
permissions: ["shared.records.read"],
|
permissions: [
|
||||||
|
{ id: "shared.records.read", baseline: "restricted", description: "Read records." },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
packageJson: {
|
packageJson: {
|
||||||
name: "@test/duplicate",
|
name: "@test/duplicate",
|
||||||
@@ -229,7 +243,9 @@ describe("module discovery", () => {
|
|||||||
...manifestFor("two"),
|
...manifestFor("two"),
|
||||||
id: "shared",
|
id: "shared",
|
||||||
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
||||||
permissions: ["shared.records.read"],
|
permissions: [
|
||||||
|
{ id: "shared.records.read", baseline: "restricted", description: "Read records." },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
packageJson: {
|
packageJson: {
|
||||||
name: "@test/duplicate",
|
name: "@test/duplicate",
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function manifestFor(
|
|||||||
} = {},
|
} = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
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> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function manifestFor(
|
|||||||
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ registry, which conflicts with install-by-cloning discovery.
|
|||||||
|
|
||||||
## Decision
|
## 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
|
`ekklesia.module.json` remains the one canonical static manifest and moves to
|
||||||
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
|
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
|
||||||
semantic version. Required and optional capability dependencies map identifiers to semantic-version
|
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
|
declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and
|
||||||
remain later work.
|
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
|
## Alternatives
|
||||||
|
|
||||||
- Representing platform-owner status as a special grant row (a synthetic `*` permission, or a
|
- 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
|
- [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
|
- [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
|
- [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
|
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
|
||||||
abuse-control design.
|
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
|
## Database connection limits
|
||||||
|
|
||||||
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
|
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
# Authorization
|
# Authorization
|
||||||
|
|
||||||
Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into
|
Phase 4C establishes Core's authorization foundation and wires it into `apps/api` over HTTP. Phase
|
||||||
`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the
|
5A extends that same system so an installed module's declared permissions are enforced and
|
||||||
platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it.
|
grantable through the identical mechanism as Core's own. This page covers all of it: the
|
||||||
See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the
|
schema/evaluator/store/registry Core owns, the platform-owner bootstrap operation, the
|
||||||
HTTP transport it is mounted into, and [ADR
|
enforcement/endpoints `apps/api` exposes, and how module manifests contribute to the one platform
|
||||||
0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable
|
permission registry. See [Identity](identity.md) for the identity foundation this builds on,
|
||||||
architectural decisions this page describes in more implementation detail.
|
[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
|
## What this is, and is not
|
||||||
|
|
||||||
After this slice, an operator can bootstrap exactly one platform owner for a deployment, a
|
An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone
|
||||||
platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization
|
they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any
|
||||||
grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of
|
registered permission, Core-owned or module-owned; and every enforced route fails closed on
|
||||||
silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants;
|
infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage
|
||||||
there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not
|
roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or
|
||||||
exposed at all in this slice); there are no service accounts or API tokens, so only
|
re-granting platform-owner status is not exposed at all); there are no service accounts or API
|
||||||
cookie-authenticated browser callers can reach the mutation routes; and relationship-derived
|
tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot
|
||||||
grants (for example, automatic access from a roster assignment) cannot yet be created by anything
|
yet mount its own HTTP routes; and relationship-derived grants (for example, automatic access from
|
||||||
in this repository — the generic grants API structurally cannot create or revoke them either.
|
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
|
## Core authorization schema and store
|
||||||
|
|
||||||
@@ -31,10 +36,19 @@ permission registry, the bootstrap operation, and HTTP enforcement.
|
|||||||
|
|
||||||
## Permission registry
|
## Permission registry
|
||||||
|
|
||||||
`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable,
|
`PermissionDeclaration` (`packages/contracts/src/authorization.ts`) is the one authoritative,
|
||||||
composition-time-built `PermissionRegistry` distinguishing a *registered declaration*
|
strict Zod-validated shape of a *registered declaration* — exactly `{id, baseline, description}`,
|
||||||
(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties
|
nothing more — shared by Core's authorization registry and a module manifest's `permissions` array.
|
||||||
matter:
|
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
|
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
|
||||||
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
|
`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
|
and return `undefined`/`false` for anything malformed or simply not registered — there is no
|
||||||
"trust this permission id because it looks right" path.
|
"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 |
|
| Permission | Baseline | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
| `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
|
## Module permission composition
|
||||||
for this slice (deferred to the Module SDK work).
|
|
||||||
|
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
|
### 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 |
|
| Field | Required | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
|
| `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. |
|
| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. |
|
||||||
| `expiresAt` | No | An ISO date string if present. |
|
| `expiresAt` | No | An ISO date string if present. |
|
||||||
|
|
||||||
@@ -287,7 +408,19 @@ there are or whether the failure happened during startup.
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
|
- `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
|
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
|
||||||
per-domain database tests).
|
per-domain database tests).
|
||||||
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`
|
- `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
|
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
|
||||||
still blocking reuse, and the concurrency scenario described above.
|
still blocking reuse, and the concurrency scenario described above.
|
||||||
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
|
- `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
|
- `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
|
and malformed-field rejection, a well-formed but unregistered target permission rejected with
|
||||||
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
||||||
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
||||||
store-error mapping.
|
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
|
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
|
||||||
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
||||||
consulting the grant or platform-owner ports for `public`/`authenticated`.
|
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
|
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
|
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
|
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
|
`core.authorization.grants.manage` itself as the granted permission), disallowed-field
|
||||||
registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection,
|
rejection, missing/untrusted-origin rejection, a real relationship-derived grant proven
|
||||||
a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an
|
invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database
|
||||||
infrastructure-failure scenario (database dropped out from under a live session) asserting a
|
dropped out from under a live session) asserting a `503` rather than a false `403` or `200`.
|
||||||
`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:
|
Run the focused suites:
|
||||||
|
|
||||||
@@ -330,7 +487,10 @@ pnpm test:migrations:integration # requires Docker
|
|||||||
|
|
||||||
## Next phase
|
## Next phase
|
||||||
|
|
||||||
Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission
|
Deliberately deferred, unrelated slices — see
|
||||||
registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service
|
[ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred)
|
||||||
accounts/API tokens for non-browser automation, and relationship-derived grant creation itself
|
for the complete list: module HTTP route-registration contracts (a module mounting its own
|
||||||
(a roster or similar module operation, not the generic grants API).
|
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
|
0 modules installed
|
||||||
✓ Dependency graph valid
|
✓ Dependency graph valid
|
||||||
Dependency order is empty.
|
Dependency order is empty.
|
||||||
|
✓ Permission registry composed
|
||||||
|
1 permission registered
|
||||||
|
|
||||||
Data definitions
|
Data definitions
|
||||||
✓ Schemas validated
|
✓ Schemas validated
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ without executing module code:
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 3,
|
||||||
"id": "calendar",
|
"id": "calendar",
|
||||||
"name": "Calendar",
|
"name": "Calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -77,7 +77,13 @@ without executing module code:
|
|||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"permissions": ["calendar.events.read"],
|
"permissions": [
|
||||||
|
{
|
||||||
|
"id": "calendar.events.read",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "Read calendar events."
|
||||||
|
}
|
||||||
|
],
|
||||||
"storage": {
|
"storage": {
|
||||||
"buckets": [
|
"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
|
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.
|
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
|
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,
|
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
|
and exist. Production never imports source TypeScript directly. Modules with no tables omit
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { permissionIdSchema } from "./identifiers.js";
|
||||||
import type { EkklesiaUserId } from "./identity.js";
|
import type { EkklesiaUserId } from "./identity.js";
|
||||||
import { parseEkklesiaUserId } 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>;
|
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
|
* 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
|
* 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 { z } from "zod";
|
||||||
|
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
|
||||||
import {
|
import {
|
||||||
capabilityIdSchema,
|
capabilityIdSchema,
|
||||||
logicalBucketIdSchema,
|
logicalBucketIdSchema,
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
packageRelativePathSchema,
|
packageRelativePathSchema,
|
||||||
permissionIdSchema,
|
|
||||||
} from "./identifiers.js";
|
} from "./identifiers.js";
|
||||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ function reportDuplicates(
|
|||||||
|
|
||||||
export const moduleManifestSchema = z
|
export const moduleManifestSchema = z
|
||||||
.object({
|
.object({
|
||||||
manifestVersion: z.literal(2),
|
manifestVersion: z.literal(3),
|
||||||
id: moduleIdSchema,
|
id: moduleIdSchema,
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
description: z.string().trim().min(1).max(500).optional(),
|
description: z.string().trim().min(1).max(500).optional(),
|
||||||
@@ -102,7 +102,7 @@ export const moduleManifestSchema = z
|
|||||||
.strict(),
|
.strict(),
|
||||||
dependencies: dependenciesSchema,
|
dependencies: dependenciesSchema,
|
||||||
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||||
permissions: z.array(permissionIdSchema).default(() => []),
|
permissions: z.array(permissionDeclarationSchema).default(() => []),
|
||||||
storage: moduleStorageSchema,
|
storage: moduleStorageSchema,
|
||||||
entrypoints: entrypointsSchema,
|
entrypoints: entrypointsSchema,
|
||||||
})
|
})
|
||||||
@@ -114,7 +114,12 @@ export const moduleManifestSchema = z
|
|||||||
context,
|
context,
|
||||||
"id",
|
"id",
|
||||||
);
|
);
|
||||||
reportDuplicates(manifest.permissions, ["permissions"], context);
|
reportDuplicates(
|
||||||
|
manifest.permissions.map((permission) => permission.id),
|
||||||
|
["permissions"],
|
||||||
|
context,
|
||||||
|
"id",
|
||||||
|
);
|
||||||
reportDuplicates(
|
reportDuplicates(
|
||||||
manifest.storage.buckets.map((bucket) => bucket.id),
|
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||||
["storage", "buckets"],
|
["storage", "buckets"],
|
||||||
@@ -133,11 +138,11 @@ export const moduleManifestSchema = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
manifest.permissions.forEach((permission, index) => {
|
manifest.permissions.forEach((permission, index) => {
|
||||||
if (!permission.startsWith(`${manifest.id}.`)) {
|
if (!permission.id.startsWith(`${manifest.id}.`)) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
|
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 ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||||
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
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,
|
isUserActor,
|
||||||
parseAuthorizationScope,
|
parseAuthorizationScope,
|
||||||
parseGrantId,
|
parseGrantId,
|
||||||
|
parsePermissionDeclaration,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
|
type PermissionDeclaration,
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
safeParseAuthorizationScope,
|
safeParseAuthorizationScope,
|
||||||
safeParseGrantId,
|
safeParseGrantId,
|
||||||
|
safeParsePermissionDeclaration,
|
||||||
safeParsePermissionId,
|
safeParsePermissionId,
|
||||||
SYSTEM_ACTOR,
|
SYSTEM_ACTOR,
|
||||||
} from "../src/index.js";
|
} 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", () => {
|
describe("GrantId", () => {
|
||||||
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
|
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
|
||||||
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import {
|
|||||||
MODULE_LIFECYCLE_STAGES,
|
MODULE_LIFECYCLE_STAGES,
|
||||||
packageRelativePathSchema,
|
packageRelativePathSchema,
|
||||||
parseModuleManifest,
|
parseModuleManifest,
|
||||||
|
parsePermissionId,
|
||||||
safeParseModuleManifest,
|
safeParseModuleManifest,
|
||||||
} from "../src/index.js";
|
} from "../src/index.js";
|
||||||
|
|
||||||
function validManifest(): Record<string, unknown> {
|
function validManifest(): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
manifestVersion: 2,
|
manifestVersion: 3,
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
name: "Calendar",
|
name: "Calendar",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
@@ -18,7 +19,13 @@ function validManifest(): Record<string, unknown> {
|
|||||||
moduleSdk: "^0.1.0",
|
moduleSdk: "^0.1.0",
|
||||||
},
|
},
|
||||||
capabilities: [{ id: "calendar.events", version: "2.3.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: {
|
storage: {
|
||||||
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
|
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", () => {
|
it("creates isolated default collections for every parsed manifest", () => {
|
||||||
const input = validManifest();
|
const input = validManifest();
|
||||||
delete input.storage;
|
delete input.storage;
|
||||||
|
delete input.permissions;
|
||||||
const first = parseModuleManifest(input);
|
const first = parseModuleManifest(input);
|
||||||
const second = parseModuleManifest(input);
|
const second = parseModuleManifest(input);
|
||||||
|
|
||||||
first.dependencies.modules.required.people = "^0.1.0";
|
first.dependencies.modules.required.people = "^0.1.0";
|
||||||
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
|
first.dependencies.capabilities.required["people.records"] = "^1.0.0";
|
||||||
first.storage.buckets.push({ id: "exports" });
|
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.modules.required).toEqual({});
|
||||||
expect(second.dependencies.capabilities.required).toEqual({});
|
expect(second.dependencies.capabilities.required).toEqual({});
|
||||||
expect(second.storage.buckets).toEqual([]);
|
expect(second.storage.buckets).toEqual([]);
|
||||||
|
expect(second.permissions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unknown and presentation-oriented manifest properties", () => {
|
it("rejects unknown and presentation-oriented manifest properties", () => {
|
||||||
@@ -68,14 +82,16 @@ describe("module manifest contract", () => {
|
|||||||
const manifest = {
|
const manifest = {
|
||||||
...validManifest(),
|
...validManifest(),
|
||||||
capabilities: [{ id: "media.items", version: "1.0.0" }],
|
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);
|
const result = safeParseModuleManifest(manifest);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
|
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", () => {
|
it("rejects module and capability self-dependencies", () => {
|
||||||
const manifest = {
|
const manifest = {
|
||||||
...validManifest(),
|
...validManifest(),
|
||||||
@@ -151,6 +193,113 @@ describe("module manifest contract", () => {
|
|||||||
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion: 1 }).success).toBe(false);
|
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([
|
it.each([
|
||||||
"../outside.ts",
|
"../outside.ts",
|
||||||
"/absolute.ts",
|
"/absolute.ts",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 3,
|
||||||
"id": "example",
|
"id": "example",
|
||||||
"name": "Example Module",
|
"name": "Example Module",
|
||||||
"description": "A minimal backend-only Ekklesia module template.",
|
"description": "A minimal backend-only Ekklesia module template.",
|
||||||
@@ -14,7 +14,13 @@
|
|||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"permissions": ["example.records.read"],
|
"permissions": [
|
||||||
|
{
|
||||||
|
"id": "example.records.read",
|
||||||
|
"baseline": "restricted",
|
||||||
|
"description": "Read example records."
|
||||||
|
}
|
||||||
|
],
|
||||||
"storage": {
|
"storage": {
|
||||||
"buckets": [
|
"buckets": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ describe("module template", () => {
|
|||||||
|
|
||||||
expect(manifest.id).toBe("example");
|
expect(manifest.id).toBe("example");
|
||||||
expect(manifest.capabilities).toEqual([{ id: "example.records", version: "1.0.0" }]);
|
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.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]);
|
||||||
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
|
||||||
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
expect(manifest.entrypoints.migrations).toBe("./drizzle");
|
||||||
|
|||||||
Reference in New Issue
Block a user