feat: integrate module authorization
Wires composePlatformPermissionRegistry into apps/api's production startup: startApi() discovers every installed module, composes the one immutable platform permission registry (Core's own declarations plus every module's), and only then constructs the shared PostgreSQL pool, Better Auth, and the HTTP server. A discovery or composition failure — an invalid manifest, a reserved core.* namespace claim, or a duplicate declaration — propagates before anything exists to close; there is no Core-only registry fallback. apps/api/src/platform-permissions.ts resolves the repository root from its own file location, never process.cwd(), so package-scoped test runs discover the right modules/ directory. createApiApp/createGrantsRoutes now require the composed registry as an injected authorization option, replacing the previous hard-coded CORE_PERMISSION_REGISTRY; requirePermission continues to work identically for Core-owned and module-owned permissions, since it has no concept of the distinction. Extends pnpm doctor with a modules.permissions check (reusing the already-discovered module list, distinguishing reserved-namespace from generic-duplicate failures, never re-discovering modules) and pnpm modules:validate to compose the same registry before a module is considered installable. Adds focused coverage: startup-ordering and failure-isolation tests in apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement tests in apps/api/tests/authorization-middleware.test.ts, Doctor composition tests in core/tests/doctor.test.ts, and a live PostgreSQL end-to-end scenario proving a real installed module's permission is discoverable, grantable, and deniable once the module is absent again after a restart, without ever executing its server entrypoint.
This commit is contained in:
@@ -6,8 +6,9 @@ surfaces.
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
|
Phase 3 is complete; Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), and
|
||||||
and Phase 4C (Core authorization) have landed. The repository includes
|
Phase 4C (Core authorization) have landed; and Phase 5A (module authorization integration) is in
|
||||||
|
progress. The repository includes
|
||||||
public module contracts, independently versioned capabilities, static module manifests,
|
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,22 @@ 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 (in progress) wires module-declared permissions into that same authorization system: a
|
||||||
|
version-3 module manifest declares permissions as complete `{id, baseline, description}`
|
||||||
|
declarations (never a bare string), and `apps/api` discovers every installed module and composes
|
||||||
|
one immutable platform permission registry — Core's own declarations plus every module's — before
|
||||||
|
constructing its database pool, Better Auth instance, or HTTP server. A module's declared
|
||||||
|
permission is then enforceable and grantable through the exact same `requirePermission` middleware
|
||||||
|
and `/api/v1/grants` HTTP API Core's own permissions already use, with `core.*` reserved and never
|
||||||
|
claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission
|
||||||
|
composition. Module HTTP route registration and relationship-derived grant creation remain later
|
||||||
|
work. See [Authorization](docs/development/authorization.md#module-permission-composition) and
|
||||||
|
[Module development](docs/module-development/README.md).
|
||||||
|
|
||||||
## Quick start
|
## 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 +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
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { PermissionRegistry } from "@ekklesia/core/authorization";
|
||||||
|
import {
|
||||||
|
composePlatformPermissionRegistry,
|
||||||
|
discoverModules,
|
||||||
|
type DiscoveredModule,
|
||||||
|
} from "@ekklesia/core/modules";
|
||||||
|
|
||||||
|
export type PlatformPermissionConfigurationErrorCode = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
||||||
|
|
||||||
|
/** Never carries the underlying package.json path, filesystem error, module directory, manifest
|
||||||
|
* content, or any other machine detail — a broken repository layout is a startup-time
|
||||||
|
* configuration problem, not something to describe in detail to whatever eventually logs it. */
|
||||||
|
export class PlatformPermissionConfigurationError extends Error {
|
||||||
|
readonly code: PlatformPermissionConfigurationErrorCode;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("Module discovery configuration could not be resolved.");
|
||||||
|
this.name = "PlatformPermissionConfigurationError";
|
||||||
|
this.code = "PLATFORM_PERMISSION_CONFIGURATION_INVALID";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the repository root from this compiled/source file's own location, never
|
||||||
|
* `process.cwd()` — package-scoped test runs execute with `apps/api` as the working directory,
|
||||||
|
* which would silently point discovery at the wrong `modules/` directory. `apps/api/dist/*.js`
|
||||||
|
* (compiled) and `apps/api/src/*.ts` (source, under a test runner transforming TypeScript
|
||||||
|
* on the fly) sit at the identical depth from the repository root, so the same fixed number of
|
||||||
|
* `..` segments resolves correctly in both.
|
||||||
|
*/
|
||||||
|
function resolveRepositoryRoot(): string {
|
||||||
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageVersionFile {
|
||||||
|
readonly version?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPackageVersion(packagePath: string): Promise<string> {
|
||||||
|
let packageData: PackageVersionFile;
|
||||||
|
try {
|
||||||
|
packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersionFile;
|
||||||
|
} catch {
|
||||||
|
throw new PlatformPermissionConfigurationError();
|
||||||
|
}
|
||||||
|
if (typeof packageData.version !== "string" || packageData.version.trim().length === 0) {
|
||||||
|
throw new PlatformPermissionConfigurationError();
|
||||||
|
}
|
||||||
|
return packageData.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComposeStartupPermissionRegistryOptions {
|
||||||
|
/** Test-only escape hatch: overrides the directory module discovery scans for installed
|
||||||
|
* modules. Never set by the executable entry point — production always discovers the real
|
||||||
|
* repository's `modules/` directory. Platform and Module SDK versions are always read from the
|
||||||
|
* real repository's own `package.json` files regardless of this override. */
|
||||||
|
readonly modulesDirectoryOverride?: string;
|
||||||
|
/** Test-only escape hatch: supplies an already-discovered module list directly, skipping the
|
||||||
|
* filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry`
|
||||||
|
* on it. Exists because a genuine exact permission-id collision between two modules — or between
|
||||||
|
* a module and Core — is unreachable through real discovery: module ids are unique (discovery
|
||||||
|
* itself rejects a duplicate) and every permission id is namespaced by its own module's id, so
|
||||||
|
* no two really-discovered modules can ever contribute the same permission id. Proving
|
||||||
|
* composition's own duplicate-rejection behavior, and that startup has created no pool before it
|
||||||
|
* runs, requires supplying deliberately conflicting declarations directly. Never set by the
|
||||||
|
* executable entry point; mutually exclusive with `modulesDirectoryOverride` in practice, since
|
||||||
|
* only one discovery-bypass is ever needed for a given scenario.
|
||||||
|
*/
|
||||||
|
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovers every installed module's manifest and composes the one immutable platform permission
|
||||||
|
* registry (Core's own declarations plus every module's) — the production source of truth
|
||||||
|
* `apps/api` enforces every request against. Only manifests are read; no module server entrypoint
|
||||||
|
* is imported or executed, and no module lifecycle hook runs. Read the platform and Module SDK
|
||||||
|
* versions only when this function actually executes, never at import time.
|
||||||
|
*/
|
||||||
|
export async function composeStartupPermissionRegistry(
|
||||||
|
options: ComposeStartupPermissionRegistryOptions = {},
|
||||||
|
): Promise<PermissionRegistry> {
|
||||||
|
if (options.discoveredModulesOverride) {
|
||||||
|
return composePlatformPermissionRegistry(options.discoveredModulesOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryRoot = resolveRepositoryRoot();
|
||||||
|
const [platformVersion, moduleSdkVersion] = await Promise.all([
|
||||||
|
readPackageVersion(path.join(repositoryRoot, "package.json")),
|
||||||
|
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const modules = await discoverModules({
|
||||||
|
moduleSdkVersion,
|
||||||
|
modulesDirectory: options.modulesDirectoryOverride ?? path.join(repositoryRoot, "modules"),
|
||||||
|
platformVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
return composePlatformPermissionRegistry(modules);
|
||||||
|
}
|
||||||
+32
-8
@@ -5,11 +5,13 @@ import {
|
|||||||
createAuthorizationStore,
|
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 {
|
||||||
@@ -1147,3 +1206,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();
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+122
-1
@@ -1,5 +1,6 @@
|
|||||||
import type { ModuleManifest } from "@ekklesia/contracts";
|
import { parsePermissionId, type ModuleManifest } from "@ekklesia/contracts";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { 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);
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -96,9 +96,73 @@ Composition itself performs no filesystem, environment, database, network, or mo
|
|||||||
operation — it is pure, synchronous, in-memory composition over data a caller already discovered
|
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
|
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
|
the other direction: authorization never imports module discovery or runtime, only the module
|
||||||
integration layer depends on authorization). This pass stops at producing the combined registry;
|
integration layer depends on authorization).
|
||||||
wiring it into `apps/api` in place of `CORE_PERMISSION_REGISTRY`, and any module-owned HTTP
|
|
||||||
enforcement built on it, is later work.
|
## API startup: discovery, composition, and enforcement
|
||||||
|
|
||||||
|
`apps/api` builds its production permission registry from the complete installed-module inventory
|
||||||
|
every time it starts, in a fixed order, before anything else:
|
||||||
|
|
||||||
|
1. Resolve module-discovery configuration (the repository's `modules/` directory, and the
|
||||||
|
platform/Module SDK versions from their own `package.json` files).
|
||||||
|
2. Discover installed modules (`discoverModules`).
|
||||||
|
3. Compose the platform permission registry (`composePlatformPermissionRegistry`).
|
||||||
|
4. Construct the shared PostgreSQL pool.
|
||||||
|
5. Construct Identity and Authorization.
|
||||||
|
6. Create the Hono application, injecting the composed registry.
|
||||||
|
7. Bind the HTTP server.
|
||||||
|
|
||||||
|
`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3.
|
||||||
|
Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server
|
||||||
|
exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the
|
||||||
|
reserved `core.*` namespace, or a duplicate permission declaration — leaves nothing to close: it
|
||||||
|
propagates directly out of `startApi()`, and startup never falls back to `CORE_PERMISSION_REGISTRY`
|
||||||
|
or any Core-only registry. The repository root is resolved from this compiled/source file's own
|
||||||
|
location (two directories up from `apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at
|
||||||
|
identical depth), never `process.cwd()` — a package-scoped test run has `apps/api` as its working
|
||||||
|
directory, which would otherwise point discovery at the wrong `modules/` directory.
|
||||||
|
|
||||||
|
The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot
|
||||||
|
installation, removal, or dynamic mutation of installed modules or their permissions while the
|
||||||
|
process is running — an installed-module change (add, remove, or edit a manifest) takes effect
|
||||||
|
only on the next API restart. `createApiApp` requires this registry as part of its injected
|
||||||
|
authorization options; `requirePermission` and the explicit-grant routes
|
||||||
|
(`GET/POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke`) consult exactly the same
|
||||||
|
registry Core's own `core.authorization.grants.manage` permission is registered in, so a
|
||||||
|
module-owned permission and Core's own are enforced, and grantable, through the identical
|
||||||
|
mechanism — there is no module-specific enforcement path. Removing a module from the next
|
||||||
|
startup's discovered inventory makes its permission unknown to new authorization checks and new
|
||||||
|
grants (a well-formed but unregistered permission denies exactly as before); it never deletes,
|
||||||
|
revokes, or otherwise modifies a historical grant or audit row already recorded for that
|
||||||
|
permission — a previously granted, still-unrevoked explicit grant for a now-uninstalled module's
|
||||||
|
permission remains visible through `GET /api/v1/grants` and simply stops being something anyone
|
||||||
|
can newly grant, revoke, or have authorized against.
|
||||||
|
|
||||||
|
Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived
|
||||||
|
grant creation both remain deferred, unrelated slices — this work only makes a module's *declared*
|
||||||
|
permission usable through Core's existing, generic explicit-grant HTTP API and enforcement
|
||||||
|
middleware.
|
||||||
|
|
||||||
|
## Doctor and modules:validate
|
||||||
|
|
||||||
|
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
||||||
|
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
||||||
|
it never re-discovers modules itself. A successful composition reports a pass with a safe
|
||||||
|
declaration count (for example `1 permission registered` with zero installed modules, since Core's
|
||||||
|
own declaration is always present); zero installed modules still composes successfully. If module
|
||||||
|
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
|
||||||
|
pattern; if discovery succeeded but composition failed, schema and migration checks still run
|
||||||
|
normally — they are independent diagnostics that do not depend on the permission registry. A
|
||||||
|
reserved-namespace violation (`ModulePermissionCompositionError`,
|
||||||
|
`MODULE_PERMISSION_RESERVED_NAMESPACE`) and a generic duplicate declaration
|
||||||
|
(`PermissionRegistryError`, `PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) are reported as distinct
|
||||||
|
failure details — a reserved `core.*` claim is never described as an ordinary duplicate, even
|
||||||
|
though (see above) a module attempting to exactly collide with a Core-owned id always surfaces as
|
||||||
|
the reserved-namespace failure specifically. Composition requires no database and never imports or
|
||||||
|
executes a module's server entrypoint. `pnpm modules:validate` composes the same registry from its
|
||||||
|
own already-discovered modules for the identical reason: a manifest that is otherwise structurally
|
||||||
|
valid but claims the `core.*` namespace, or duplicates a permission id, now fails validation before
|
||||||
|
that module is considered installable.
|
||||||
|
|
||||||
### Two layers, not one: shape versus registration
|
### Two layers, not one: shape versus registration
|
||||||
|
|
||||||
@@ -265,7 +329,7 @@ cannot smuggle a server-derived field through a request that otherwise looks wel
|
|||||||
| Field | Required | Notes |
|
| 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. |
|
||||||
|
|
||||||
@@ -357,13 +421,27 @@ there are or whether the failure happened during startup.
|
|||||||
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
|
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`.
|
||||||
@@ -373,11 +451,21 @@ there are or whether the failure happened during startup.
|
|||||||
account then managing grants as a non-owner, a well-formed but unregistered target permission
|
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:
|
||||||
|
|
||||||
@@ -389,7 +477,8 @@ pnpm test:migrations:integration # requires Docker
|
|||||||
|
|
||||||
## Next phase
|
## Next phase
|
||||||
|
|
||||||
Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission
|
Phase 5A, pass 3: deferred from this slice — module HTTP route-registration contracts (a module
|
||||||
registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service
|
mounting its own endpoints), a module runtime-context authorization service, relationship-derived
|
||||||
accounts/API tokens for non-browser automation, and relationship-derived grant creation itself
|
grant creation itself (a roster or similar module operation, not the generic grants API),
|
||||||
(a roster or similar module operation, not the generic grants API).
|
roles/groups/teams/tenants, an owner-management HTTP endpoint, and service accounts/API tokens for
|
||||||
|
non-browser automation.
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ Modules
|
|||||||
0 modules installed
|
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
|
||||||
|
|||||||
@@ -121,6 +121,17 @@ every discovered module's declarations, alongside its own, into the one platform
|
|||||||
registry). A duplicate permission id within one manifest, or a permission id outside the module's
|
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.
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user