feat: integrate module authorization #11
@@ -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