Merge pull request 'phase-4/core-authorization' (#10) from phase-4/core-authorization into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 28s

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/10
This commit was merged in pull request #10.
This commit is contained in:
The_River
2026-07-23 16:52:39 +00:00
51 changed files with 6405 additions and 66 deletions
+20 -7
View File
@@ -6,8 +6,8 @@ surfaces.
## Current status ## Current status
Phase 3 is complete, and Phase 4A (Core identity foundation) and Phase 4B (identity HTTP Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
transport) have landed. The repository includes and Phase 4C (Core authorization) have landed. The repository includes
public module contracts, independently versioned capabilities, static module manifests, public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry, deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
@@ -35,10 +35,21 @@ still cannot reach any of this. Public email/password registration is intentiona
receives identical lowest-privilege baseline status and signup order never grants elevation. See receives identical lowest-privilege baseline status and signup order never grants elevation. See
[API](docs/development/api.md) and [API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
**No email verification, password recovery, Dashboard, or role/permission model exists yet** — **No email verification, password recovery, or Dashboard exists yet** — that, along with
that, along with production approval-provider composition, jobs, physical storage provisioning, production approval-provider composition, jobs, physical storage provisioning, surfaces, and
surfaces, and feature modules, remains later vertical slices. Public registration is not yet ready feature modules, remains later vertical slices. Public registration is not yet ready for a real
for a real public launch. public launch.
Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store
for platform owners, explicit grants, and relationship-derived grants; a smallest-possible
immutable permission registry; an explicit, single-use, concurrency-safe platform-owner bootstrap
command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup
order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped
`/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin`
on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, service
accounts/API tokens, and module-permission registration remain later work. See
[Authorization](docs/development/authorization.md) and
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
## Quick start ## Quick start
@@ -83,8 +94,9 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
1 migration · checksums valid 2 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
PostgreSQL PostgreSQL
○ Connection check skipped ○ Connection check skipped
@@ -146,6 +158,7 @@ environment.
- [Testing](docs/development/testing.md) - [Testing](docs/development/testing.md)
- [Identity](docs/development/identity.md) - [Identity](docs/development/identity.md)
- [API](docs/development/api.md) - [API](docs/development/api.md)
- [Authorization](docs/development/authorization.md)
- [Module development](docs/module-development/README.md) - [Module development](docs/module-development/README.md)
- [Architecture decisions](docs/architecture/decisions/README.md) - [Architecture decisions](docs/architecture/decisions/README.md)
+27 -2
View File
@@ -1,13 +1,27 @@
import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
CORE_PERMISSION_REGISTRY,
} 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";
import type { Context } from "hono"; import type { Context } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { createGrantsRoutes } from "./grants-routes.js";
export interface CreateApiAppAuthorizationOptions {
readonly evaluator: AuthorizationEvaluator;
readonly store: AuthorizationStore;
}
export interface CreateApiAppOptions { export interface CreateApiAppOptions {
readonly authorization: CreateApiAppAuthorizationOptions;
readonly runtime: EkklesiaIdentityRuntime; readonly runtime: EkklesiaIdentityRuntime;
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. */ * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
* `requireTrustedOrigin` checks for grant-mutation routes — there is no second, competing origin
* configuration. */
readonly trustedOrigins: readonly string[]; readonly trustedOrigins: readonly string[];
} }
@@ -20,7 +34,7 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
/** /**
* Pure Hono application factory: no I/O, no environment reads, no process side effects. Suitable * Pure Hono application factory: no I/O, no environment reads, no process side effects. Suitable
* for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime * for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime
* standing in for real Better Auth/PostgreSQL where appropriate. * and authorization evaluator/store standing in for real Better Auth/PostgreSQL where appropriate.
*/ */
export function createApiApp(options: CreateApiAppOptions): Hono { export function createApiApp(options: CreateApiAppOptions): Hono {
const { runtime } = options; const { runtime } = options;
@@ -62,6 +76,17 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
} }
}); });
app.route(
"/",
createGrantsRoutes({
evaluator: options.authorization.evaluator,
registry: CORE_PERMISSION_REGISTRY,
runtime,
store: options.authorization.store,
trustedOrigins: allowedOrigins,
}),
);
app.notFound((c) => { app.notFound((c) => {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");
return c.json({ code: "NOT_FOUND" }, 404); return c.json({ code: "NOT_FOUND" }, 404);
+109
View File
@@ -0,0 +1,109 @@
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
import type { AuthorizationEvaluator, PermissionRegistry } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import type { Context, MiddlewareHandler, Next } from "hono";
const PRINCIPAL_CONTEXT_KEY = "ekklesiaPrincipal";
export interface AuthorizationMiddlewareOptions {
readonly evaluator: AuthorizationEvaluator;
readonly registry: PermissionRegistry;
readonly runtime: EkklesiaIdentityRuntime;
}
function sanitizedDenial(c: Context, status: 403 | 503, code: string): Response {
c.header("Cache-Control", "no-store");
return c.json({ code }, status);
}
/**
* Builds a Hono middleware that enforces `permissionId`, resolved exclusively from Core's own
* trusted, server-side permission registry — a route never supplies its own baseline, and no
* request input can influence which baseline is checked. The principal is resolved exclusively
* through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal`
* already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or
* grants. A permission id that is not registered fails closed (403) exactly like a denied
* decision — in normal operation this route only ever hard-codes an already-registered constant,
* but the check stays live per-request as defense in depth rather than being trusted once at
* composition time.
*
* On success, the resolved `EkklesiaPrincipal` — Core's own narrow `{kind, userId}` contract, not
* Better Auth's richer session/user object — is stored on the Hono context so the route handler
* can build an actor without resolving the principal a second time. This is deliberately narrower
* than the "no generalized session/user object in Hono context" concern ADR 0011 raised about
* Better Auth's own documented example: only Core's already-public-safe principal shape is ever
* set, never a session token, raw user row, or Better Auth type.
*
* A genuine infrastructure failure — principal resolution, evaluation, or the underlying
* database — is never folded into a 403; it returns a sanitized 503, mirroring
* `/api/v1/principal`'s existing behavior for the identical reason: a caller must never see "no
* permission" when the true answer is "could not be determined."
*/
export function requirePermission(
options: AuthorizationMiddlewareOptions,
permissionId: PermissionId,
): MiddlewareHandler {
return async (c: Context, next: Next) => {
const declaration = options.registry.get(permissionId);
if (!declaration) {
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
}
let principal: EkklesiaPrincipal;
try {
principal = await options.runtime.resolvePrincipal(c.req.raw.headers);
} catch {
return sanitizedDenial(c, 503, "PRINCIPAL_RESOLUTION_FAILED");
}
let decision: { allowed: boolean };
try {
decision = await options.evaluator.authorize({
baseline: declaration.baseline,
permission: declaration.id,
principal,
});
} catch {
return sanitizedDenial(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
}
if (!decision.allowed) {
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
}
c.set(PRINCIPAL_CONTEXT_KEY, principal);
await next();
return;
};
}
/** Reads the principal a preceding `requirePermission` middleware already resolved and verified.
* Throws (a route-wiring bug, not a runtime condition) if called on a route this middleware never
* protected. */
export function getAuthorizedPrincipal(c: Context): EkklesiaPrincipal {
const principal = c.get(PRINCIPAL_CONTEXT_KEY) as EkklesiaPrincipal | undefined;
if (!principal) {
throw new Error("getAuthorizedPrincipal called outside a requirePermission-protected route.");
}
return principal;
}
/**
* Requires an exact trusted `Origin` header for a cookie-authenticated, state-changing route.
* CORS response headers alone are not CSRF protection — they only stop a browser from *reading* a
* cross-origin response, not from *sending* the state-changing request in the first place — so
* mutation routes need this explicit, server-side check in addition to (not instead of) the
* existing CORS policy. A missing or untrusted origin is rejected before the route (or the
* permission check) ever runs. Non-browser, non-cookie automation (service accounts/API tokens)
* is deliberately out of scope for this slice and has no way to satisfy this check yet.
*/
export function requireTrustedOrigin(trustedOrigins: ReadonlySet<string>): MiddlewareHandler {
return async (c: Context, next: Next) => {
const origin = c.req.header("origin");
if (!origin || !trustedOrigins.has(origin)) {
return sanitizedDenial(c, 403, "UNTRUSTED_ORIGIN");
}
await next();
return;
};
}
+36
View File
@@ -0,0 +1,36 @@
import {
type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView,
} from "@ekklesia/core/authorization";
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
export interface ApiDatabaseHandle {
readonly authorization: AuthorizationDrizzleDatabase;
readonly identity: IdentityDrizzleDatabase;
/** Closes the one shared pool exactly once, regardless of how many typed views were created
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
readonly close: () => Promise<void>;
}
/**
* Composes the one physical PostgreSQL pool this process uses for both Core's identity and
* authorization schemas: one validated, timeout-bounded `pg.Pool` (`createValidatedPostgresPool`,
* `@ekklesia/core/database`), wrapped by two independent typed Drizzle views
* (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Neither Core domain owns or
* closes the pool in this composition — `close()` here is the only thing that ever calls
* `pool.end()`. `createIdentityDrizzleDatabase`/`createAuthorizationDrizzleDatabase` remain
* available, unaffected, and independently testable for callers that only need one domain.
*/
export function createApiDatabase(
databaseUrl: string,
options: PostgresPoolOptions = {},
): ApiDatabaseHandle {
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
return Object.freeze({
authorization: createAuthorizationDrizzleView(pool),
close,
identity: createIdentityDrizzleView(pool),
});
}
+222
View File
@@ -0,0 +1,222 @@
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
import {
createUserActor,
isAuthenticatedPrincipal,
safeParseAuthorizationScope,
safeParseEkklesiaUserId,
safeParseGrantId,
safeParsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
AuthorizationStoreError,
CORE_AUTHORIZATION_GRANTS_MANAGE,
type GrantRecord,
} from "@ekklesia/core/authorization";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
readonly store: AuthorizationStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
c.header("Cache-Control", "no-store");
if (error instanceof AuthorizationStoreError) {
const status = error.code === "AUTHORIZATION_GRANT_NOT_FOUND" ? 404 : 503;
return c.json({ code: error.code }, status);
}
return c.json({ code: "INTERNAL_ERROR" }, 503);
}
interface ParsedCreateGrantBody {
readonly expiresAt?: Date;
readonly permission: PermissionId;
readonly scope?: AuthorizationScope;
readonly subjectUserId: EkklesiaUserId;
}
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "scope", "expiresAt"]);
/**
* Accepts only `subjectUserId`, `permission`, an optional `scope`, and an optional `expiresAt`.
* Any other key present — `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`,
* `revokedAt`, a grant id, or anything claiming owner/effective-permission state — makes the
* whole body invalid rather than being silently dropped, so a caller cannot smuggle
* server-derived fields through a request that otherwise looks well-formed.
*/
function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return undefined;
}
const body = raw as Record<string, unknown>;
if (Object.keys(body).some((key) => !CREATE_GRANT_ALLOWED_KEYS.has(key))) {
return undefined;
}
const subjectUserId = safeParseEkklesiaUserId(body.subjectUserId);
if (!subjectUserId.success) {
return undefined;
}
const permission = safeParsePermissionId(body.permission);
if (!permission.success) {
return undefined;
}
let scope: AuthorizationScope | undefined;
if (body.scope !== undefined) {
const parsedScope = safeParseAuthorizationScope(body.scope);
if (!parsedScope.success) {
return undefined;
}
scope = parsedScope.data;
}
let expiresAt: Date | undefined;
if (body.expiresAt !== undefined) {
if (typeof body.expiresAt !== "string") {
return undefined;
}
const parsedDate = new Date(body.expiresAt);
if (Number.isNaN(parsedDate.getTime())) {
return undefined;
}
expiresAt = parsedDate;
}
return Object.freeze({
...(expiresAt === undefined ? {} : { expiresAt }),
permission: permission.data,
...(scope === undefined ? {} : { scope }),
subjectUserId: subjectUserId.data,
});
}
function serializeGrant(grant: GrantRecord) {
return {
createdAt: grant.createdAt.toISOString(),
expiresAt: grant.expiresAt ? grant.expiresAt.toISOString() : null,
id: grant.id,
permission: grant.permission,
revokedAt: grant.revokedAt ? grant.revokedAt.toISOString() : null,
scope: grant.scope ?? null,
sourceKind: grant.sourceKind,
userId: grant.userId,
};
}
/**
* The v1 explicit-grant management API: list, create, and revoke *explicit* grants only. Every
* route requires `core.authorization.grants.manage`; a platform owner passes through the
* evaluator's existing owner bypass without ever needing an explicit grant of this permission
* themselves — that bypass is exactly how a platform owner deliberately delegates grant
* administration to another account by granting them this one permission.
*
* Relationship-derived grants are never reachable through this API in either direction: creation
* always hard-codes `sourceKind: "explicit"` server-side (the request body cannot supply a
* `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting
* to revoke a relationship-derived grant by id behaves exactly like the grant does not exist —
* those grants remain owned by the module operation that established the relationship.
*/
export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
const routes = new Hono();
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
routes.get("/api/v1/grants", requireManagePermission, async (c) => {
c.header("Cache-Control", "no-store");
const subjectUserId = safeParseEkklesiaUserId(c.req.query("userId"));
if (!subjectUserId.success) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
try {
const grants = await options.store.listGrantsForUser(subjectUserId.data, {
sourceKind: "explicit",
});
return c.json({ grants: grants.map(serializeGrant) }, 200);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
});
routes.post("/api/v1/grants", requireOrigin, requireManagePermission, async (c) => {
c.header("Cache-Control", "no-store");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
let rawBody: unknown;
try {
rawBody = await c.req.json();
} catch {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
const parsed = parseCreateGrantBody(rawBody);
if (!parsed) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
// A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry
// (never the request) decides what may be granted — the same registry `requireManagePermission`
// already consults for the caller's own `core.authorization.grants.manage` check — so a caller
// cannot pre-create a dormant grant for an invented identifier that later becomes effective the
// moment someone registers it. Sanitized identically to any other denial: this must never reveal
// whether the identifier was malformed (400, above) or simply unregistered (403, here).
if (!options.registry.has(parsed.permission)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
try {
const grant = await options.store.createGrant({
actor: createUserActor(principal.userId),
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
permission: parsed.permission,
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
sourceKind: "explicit",
userId: parsed.subjectUserId,
});
return c.json({ grant: serializeGrant(grant) }, 201);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
});
routes.post(
"/api/v1/grants/:grantId/revoke",
requireOrigin,
requireManagePermission,
async (c) => {
c.header("Cache-Control", "no-store");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
const grantId = safeParseGrantId(c.req.param("grantId"));
if (!grantId.success) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
try {
await options.store.revokeGrant({
actor: createUserActor(principal.userId),
grantId: grantId.data,
sourceKind: "explicit",
});
return c.json({ success: true }, 200);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
},
);
return routes;
}
+27 -14
View File
@@ -1,15 +1,15 @@
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { import {
createIdentityDrizzleDatabase, createAuthorizationEvaluator,
createIdentityRuntime, createAuthorizationStore,
type IdentityDatabaseHandle, } from "@ekklesia/core/authorization";
type IdentityRateLimitOverride, import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
} from "@ekklesia/core/identity";
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";
const connectionTimeoutMs = 10_000; const connectionTimeoutMs = 10_000;
const operationTimeoutMs = 10_000; const operationTimeoutMs = 10_000;
@@ -26,7 +26,7 @@ export interface StartApiOptions {
readonly rateLimitOverride?: IdentityRateLimitOverride; readonly rateLimitOverride?: IdentityRateLimitOverride;
} }
async function safeCloseDatabase(handle: IdentityDatabaseHandle): Promise<void> { async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
try { try {
await handle.close(); await handle.close();
} catch { } catch {
@@ -54,34 +54,47 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
} }
/** /**
* Explicit runtime composition: constructs the PostgreSQL pool and Drizzle database, the Core * Explicit runtime composition: constructs the one shared PostgreSQL pool (identity and
* identity runtime, and the Hono application, then starts the Node HTTP server. Returns a handle * authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity
* with an idempotent `shutdown()` so both the executable entry point and tests can stop the * runtime, the Core authorization store and evaluator, and the Hono application, then starts the
* server and close the database pool deterministically. Never applies migrations or generates * Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry
* schema — the database must already be migrated through `pnpm migrations:apply`. * 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 databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, { const databaseHandle = createApiDatabase(config.databaseUrl, {
connectionTimeoutMs, connectionTimeoutMs,
operationTimeoutMs, operationTimeoutMs,
}); });
let runtime: ReturnType<typeof createIdentityRuntime>; let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
try { try {
runtime = createIdentityRuntime({ runtime = createIdentityRuntime({
config: config.identity, config: config.identity,
db: databaseHandle.db, db: databaseHandle.identity,
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
}); });
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
} catch (error) { } catch (error) {
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
const app = createApiApp({ runtime, trustedOrigins: config.identity.trustedOrigins }); const evaluator = createAuthorizationEvaluator({
grants: authorizationStore,
platformOwners: authorizationStore,
});
const app = createApiApp({
authorization: { evaluator, store: authorizationStore },
runtime,
trustedOrigins: config.identity.trustedOrigins,
});
let server: ServerType; let server: ServerType;
try { try {
+341 -1
View File
@@ -2,6 +2,12 @@ import { randomBytes } from "node:crypto";
import { request as httpRequest } from "node:http"; import { request as httpRequest } from "node:http";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import path from "node:path"; import path from "node:path";
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
import {
bootstrapPlatformOwner,
createAuthorizationDrizzleDatabase,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import { import {
applyModuleMigrations, applyModuleMigrations,
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
@@ -174,13 +180,18 @@ async function applyCoreMigrations(url: string): Promise<void> {
platformVersion: "0.1.0", platformVersion: "0.1.0",
}, },
}); });
// Phase 4A's committed migration must not have drifted while building this transport slice. // Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
// this transport slice.
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables", "core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
); );
expect(aggregate.migrations[1]?.checksum).toBe(
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
await rm(emptyModulesDirectory, { recursive: true, force: true }); await rm(emptyModulesDirectory, { recursive: true, force: true });
@@ -807,3 +818,332 @@ describe.sequential("live API — Better Auth logging never leaks operational de
} }
}); });
}); });
describe.sequential("live API — platform owner and delegated grant management", () => {
let database: { name: string; url: string };
let running: RunningApi;
let origin: string;
let ownerUserId: string;
let ownerCookie: string;
let managerUserId: string;
let managerCookie: string;
let createdGrantId: string;
const ownerEmail = "[email protected]";
const managerEmail = "[email protected]";
const password = "correct horse battery staple";
// Module permission registration does not exist yet, so `core.authorization.grants.manage` —
// the one permission `CORE_PERMISSION_REGISTRY` actually declares — stands in for "some
// registered permission" throughout this scenario's explicit-grant lifecycle.
const testPermission = "core.authorization.grants.manage";
// Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the
// live route rejects it even for a caller who otherwise has full grant-management authority.
const unregisteredPermission = "calendar.events.read";
it("starts a freshly migrated API and signs up the eventual owner and eventual delegated manager", async () => {
database = await createDisposableDatabase("authz_http");
await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url);
origin = apiOrigin(running);
const ownerSignUp = await signUp(origin, ownerEmail, password);
ownerUserId = ownerSignUp.body.user.id;
const managerSignUp = await signUp(origin, managerEmail, password);
managerUserId = managerSignUp.body.user.id;
const ownerSignIn = await signIn(origin, ownerEmail, password);
expect(ownerSignIn.cookiePair).toBeDefined();
ownerCookie = ownerSignIn.cookiePair as string;
});
it("denies grants access to an anonymous caller", async () => {
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("denies grants access to an authenticated caller with no owner authority and no grant", async () => {
const managerSignInBefore = await signIn(origin, managerEmail, password);
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: managerSignInBefore.cookiePair as string },
});
expect(response.status).toBe(403);
});
it("bootstraps the owner via Core's real bootstrap operation, then confirms owner authority over the HTTP grants API", async () => {
// Exactly what `pnpm authorization:bootstrap-owner` would do, driven directly against the
// same disposable database the running API already uses — no second process needed to prove
// the HTTP layer honors real platform-owner state.
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) });
} finally {
await close();
}
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: ownerCookie },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ grants: [] });
});
it("lets the owner delegate grants.manage to another account through the explicit-grant HTTP API", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
permission: "core.authorization.grants.manage",
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
const body = (await response.json()) as { grant: { sourceKind: string; userId: string } };
expect(response.status).toBe(201);
expect(body.grant.sourceKind).toBe("explicit");
expect(body.grant.userId).toBe(managerUserId);
});
it("lets the delegated manager — who is not a platform owner — manage another user's grants", async () => {
const managerSignIn = await signIn(origin, managerEmail, password);
expect(managerSignIn.cookiePair).toBeDefined();
managerCookie = managerSignIn.cookiePair as string;
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: managerCookie },
});
expect(response.status).toBe(200);
});
it("rejects a well-formed but unregistered permission even from a caller with full grant-management authority, and writes no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url);
try {
const grantCountBefore = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
[unregisteredPermission],
);
const auditCountBefore = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
[unregisteredPermission],
);
expect(grantCountBefore[0]?.count).toBe("0");
expect(auditCountBefore[0]?.count).toBe("0");
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
permission: unregisteredPermission,
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: managerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
const grantCountAfter = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
[unregisteredPermission],
);
const auditCountAfter = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
[unregisteredPermission],
);
expect(grantCountAfter[0]?.count).toBe("0");
expect(auditCountAfter[0]?.count).toBe("0");
} finally {
await safeEnd(admin);
}
});
it("creates an explicit grant via HTTP, deriving sourceKind and actor server-side, and records an atomic audit entry", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
permission: testPermission,
scope: "calendar-event:1",
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: managerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
const body = (await response.json()) as {
grant: { id: string; sourceKind: string; userId: string };
};
expect(response.status).toBe(201);
expect(body.grant.sourceKind).toBe("explicit");
expect(body.grant.userId).toBe(managerUserId);
createdGrantId = body.grant.id;
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ action: string; actor_id: string | null }>(
admin,
`select action, actor_id from "ekklesia_core_authorization_audit" where grant_id = $1`,
[createdGrantId],
);
expect(rows).toEqual([{ action: "grant.create", actor_id: managerUserId }]);
} finally {
await safeEnd(admin);
}
});
it("lists the explicit grant via HTTP", async () => {
const response = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, {
headers: { cookie: ownerCookie },
});
const body = (await response.json()) as { grants: Array<{ id: string }> };
expect(response.status).toBe(200);
expect(body.grants.some((grant) => grant.id === createdGrantId)).toBe(true);
});
it("rejects a create-grant request carrying disallowed fields such as actor or sourceKind, even alongside otherwise-valid fields", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
actor: { type: "system" },
permission: testPermission,
sourceKind: "relationship",
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(400);
});
it("rejects a mutation request with no trusted Origin header, and never returns a permissive CORS header", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }),
headers: { "content-type": "application/json", cookie: ownerCookie },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
it("rejects a mutation request from an untrusted Origin the same way", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: untrustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
it("revokes the explicit grant via HTTP and records the revocation audit entry", async () => {
const response = await fetch(`${origin}/api/v1/grants/${createdGrantId}/revoke`, {
headers: { cookie: ownerCookie, origin: dashboardOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true });
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`,
[createdGrantId],
);
expect(rows.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
} finally {
await safeEnd(admin);
}
});
it("never exposes a relationship-derived grant through the generic list, create, or revoke HTTP API", async () => {
// Seeded directly through the store, simulating a module's own business operation — never
// through HTTP, which has no way to express sourceKind at all.
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
let relationshipGrantId: string;
try {
const store = createAuthorizationStore(db);
const relationshipGrant = await store.createGrant({
actor: SYSTEM_ACTOR,
permission: parsePermissionId("roster.volunteer.read"),
sourceKind: "relationship",
sourceRef: "roster:volunteer:99",
userId: parseEkklesiaUserId(managerUserId),
});
relationshipGrantId = relationshipGrant.id;
} finally {
await close();
}
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, {
headers: { cookie: ownerCookie },
});
const listBody = (await listResponse.json()) as { grants: Array<{ id: string }> };
expect(listBody.grants.some((grant) => grant.id === relationshipGrantId)).toBe(false);
const revokeResponse = await fetch(`${origin}/api/v1/grants/${relationshipGrantId}/revoke`, {
headers: { cookie: ownerCookie, origin: dashboardOrigin },
method: "POST",
});
expect(revokeResponse.status).toBe(404);
expect(await revokeResponse.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
});
it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
});
});
describe.sequential("live API — authorization infrastructure failure is never downgraded to a false denial", () => {
it("returns a sanitized 503, not a 403, when the database becomes unreachable mid-session", async () => {
const database = await createDisposableDatabase("authz_infra_failure");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
const origin = apiOrigin(running);
await signUp(origin, "[email protected]", "correct horse battery staple");
const { cookiePair } = await signIn(
origin,
"[email protected]",
"correct horse battery staple",
);
expect(cookiePair).toBeDefined();
// Force the database itself unreachable out from under the running API pool — the same
// technique the identity infra-failure test above uses, applied to a grants route this time.
await dropDatabase(database.name);
const response = await fetch(`${origin}/api/v1/grants?userId=does-not-matter`, {
headers: { cookie: cookiePair as string },
});
expect(response.status).toBe(503);
const body = (await response.json()) as { code: string };
expect(["PRINCIPAL_RESOLUTION_FAILED", "AUTHORIZATION_EVALUATION_FAILED"]).toContain(body.code);
await running.shutdown().catch(() => undefined);
});
});
+122 -19
View File
@@ -1,8 +1,9 @@
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts"; import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts";
import type { AuthorizationEvaluator, AuthorizationStore } 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";
import { createApiApp } from "../src/app.js"; import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
const trustedOrigin = "https://dashboard.example.test"; const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test"; const untrustedOrigin = "https://evil.example.test";
@@ -15,10 +16,37 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
}; };
} }
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
* specific decision or store behavior override the relevant method explicitly. */
function fakeAuthorization(overrides: {
readonly evaluator?: Partial<AuthorizationEvaluator>;
readonly store?: Partial<AuthorizationStore>;
}): CreateApiAppAuthorizationOptions {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
...overrides.evaluator,
};
const store: AuthorizationStore = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue([]),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
...overrides.store,
};
return { evaluator, store };
}
describe("Ekklesia API Hono application", () => { describe("Ekklesia API Hono application", () => {
it("mounts Better Auth exactly under /api/auth/*", async () => { it("mounts Better Auth exactly under /api/auth/*", async () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
@@ -29,7 +57,11 @@ describe("Ekklesia API Hono application", () => {
it("does not route requests outside /api/auth/* to Better Auth", async () => { it("does not route requests outside /api/auth/* to Better Auth", async () => {
const runtime = fakeRuntime(); const runtime = fakeRuntime();
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/sign-in/email", { method: "POST" }); const response = await app.request("/sign-in/email", { method: "POST" });
@@ -41,7 +73,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime({ const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
}); });
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal"); const response = await app.request("/api/v1/principal");
@@ -55,7 +91,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime({ const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")), resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
}); });
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal"); const response = await app.request("/api/v1/principal");
const body = (await response.json()) as Record<string, unknown>; const body = (await response.json()) as Record<string, unknown>;
@@ -71,7 +111,11 @@ describe("Ekklesia API Hono application", () => {
.fn() .fn()
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")), .mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
}); });
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal"); const response = await app.request("/api/v1/principal");
@@ -84,7 +128,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime({ const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")), handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
}); });
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
const body = await response.json(); const body = await response.json();
@@ -96,7 +144,11 @@ describe("Ekklesia API Hono application", () => {
}); });
it("has deterministic 404 behavior for an unknown route", async () => { it("has deterministic 404 behavior for an unknown route", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/does-not-exist"); const response = await app.request("/does-not-exist");
@@ -105,7 +157,11 @@ describe("Ekklesia API Hono application", () => {
}); });
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => { it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", { const response = await app.request("/api/v1/principal", {
headers: { origin: trustedOrigin }, headers: { origin: trustedOrigin },
@@ -116,7 +172,11 @@ describe("Ekklesia API Hono application", () => {
}); });
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => { it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", { const response = await app.request("/api/v1/principal", {
headers: { origin: untrustedOrigin }, headers: { origin: untrustedOrigin },
@@ -126,7 +186,11 @@ describe("Ekklesia API Hono application", () => {
}); });
it("supports requests with no Origin header at all (native/server clients)", async () => { it("supports requests with no Origin header at all (native/server clients)", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal"); const response = await app.request("/api/v1/principal");
@@ -134,7 +198,11 @@ describe("Ekklesia API Hono application", () => {
}); });
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => { it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", { const response = await app.request("/api/v1/principal", {
headers: { headers: {
@@ -149,7 +217,11 @@ describe("Ekklesia API Hono application", () => {
}); });
it("never wildcards Access-Control-Allow-Origin", async () => { it("never wildcards Access-Control-Allow-Origin", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", { const response = await app.request("/api/v1/principal", {
headers: { origin: trustedOrigin }, headers: { origin: trustedOrigin },
@@ -158,10 +230,41 @@ describe("Ekklesia API Hono application", () => {
expect(response.headers.get("access-control-allow-origin")).not.toBe("*"); expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
}); });
// "No generalized session/user object stored in Hono context" is verified by construction // Unlike Better Auth's own documented Hono example (which hydrates `c.set("user", session.user)`
// rather than by a black-box context probe: unlike Better Auth's own documented Hono example // on every request), createApiApp only ever calls `c.set` once, for the narrow EkklesiaPrincipal
// (which hydrates `c.set("user", session.user)` on every request), createApiApp never calls // (kind + userId only), and only on routes `requirePermission` actually protects — proven by the
// `c.set` at all — the only per-request identity data that ever exists is the narrow // grants-routes tests, not by a black-box context probe here. `/api/v1/principal` itself still
// EkklesiaPrincipal returned directly from the /api/v1/principal handler above, whose exact // never touches `c.set`; its response shape is asserted directly above.
// shape (kind + userId only) is already asserted by the principal-shape tests in this file.
describe("grants routes mounting (see grants-routes.test.ts for full coverage)", () => {
it("mounts the v1 grants routes under /api/v1/grants", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
const app = createApiApp({
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/grants?userId=user_123");
// Reaching the permission check (and being denied by it) proves the route is mounted and
// wired to the injected evaluator, rather than falling through to the 404 handler.
expect(response.status).toBe(403);
expect(evaluatorAuthorize).toHaveBeenCalledTimes(1);
});
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/grants?userId=user_123", {
headers: { origin: untrustedOrigin },
});
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
});
}); });
@@ -0,0 +1,204 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
createPermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "../src/authorization-middleware.js";
const testPermission = parsePermissionId("core.test.restricted-thing");
const unregisteredPermission = parsePermissionId("core.test.unregistered-thing");
const registry = createPermissionRegistry([
{ baseline: "restricted", description: "A restricted test permission.", id: testPermission },
]);
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
...overrides,
};
}
function fakeEvaluator(overrides: Partial<AuthorizationEvaluator> = {}): AuthorizationEvaluator {
return {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
...overrides,
};
}
function buildTestApp(options: Partial<AuthorizationMiddlewareOptions> = {}) {
const runtime = options.runtime ?? fakeRuntime();
const evaluator = options.evaluator ?? fakeEvaluator();
const middlewareOptions: AuthorizationMiddlewareOptions = { evaluator, registry, runtime };
const app = new Hono();
app.get("/protected", requirePermission(middlewareOptions, testPermission), (c) => {
const principal = getAuthorizedPrincipal(c);
return c.json({ principalKind: principal.kind });
});
app.get("/unregistered", requirePermission(middlewareOptions, unregisteredPermission), (c) =>
c.json({ ok: true }),
);
app.get("/unprotected", (c) => {
let threw = false;
try {
getAuthorizedPrincipal(c);
} catch {
threw = true;
}
return c.json({ threw });
});
return { app, evaluator, runtime };
}
describe("requirePermission", () => {
it("allows the request through and stores the resolved principal on context when the decision is allowed", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
});
const { app } = buildTestApp({ runtime });
const response = await app.request("/protected");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ principalKind: "authenticated" });
});
it("returns a sanitized 403 when the evaluator denies", async () => {
const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) });
const { app } = buildTestApp({ evaluator });
const response = await app.request("/protected");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("returns a sanitized 403 for an unregistered permission without ever resolving the principal", async () => {
const runtime = fakeRuntime();
const { app } = buildTestApp({ runtime });
const response = await app.request("/unregistered");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
});
it("returns a sanitized 503, not a 403, when principal resolution fails", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")),
});
const { app } = buildTestApp({ runtime });
const response = await app.request("/protected");
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
});
it("returns a sanitized 503, not a 403, when evaluation itself fails", async () => {
const evaluator = fakeEvaluator({
authorize: vi.fn().mockRejectedValue(new Error("connection terminated")),
});
const { app } = buildTestApp({ evaluator });
const response = await app.request("/protected");
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
it("never leaks a raw infrastructure error message through either sanitized 503", async () => {
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
const evaluator = fakeEvaluator({
authorize: vi.fn().mockRejectedValue(new Error(hostileMessage)),
});
const { app } = buildTestApp({ evaluator });
const response = await app.request("/protected");
const text = await response.text();
expect(text).not.toContain("hunter2");
expect(text).not.toContain("db.internal.example");
});
it("passes the registry's own baseline and permission id to the evaluator, never a client-supplied value", async () => {
const evaluator = fakeEvaluator();
const { app } = buildTestApp({ evaluator });
// A hostile client cannot even express "use this baseline instead" — there is no request
// field the middleware reads for it — but sending an unrelated header proves it is ignored.
await app.request("/protected", { headers: { "x-baseline": "public" } });
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "restricted", permission: testPermission }),
);
});
});
describe("getAuthorizedPrincipal", () => {
it("throws when called on a route requirePermission never protected", async () => {
const { app } = buildTestApp();
const response = await app.request("/unprotected");
expect(await response.json()).toEqual({ threw: true });
});
});
describe("requireTrustedOrigin", () => {
const trustedOrigins = new Set(["https://dashboard.example.test"]);
function buildOriginApp() {
const app = new Hono();
app.post("/mutate", requireTrustedOrigin(trustedOrigins), (c) => c.json({ ok: true }));
return app;
}
it("allows a request from a trusted origin", async () => {
const app = buildOriginApp();
const response = await app.request("/mutate", {
headers: { origin: "https://dashboard.example.test" },
method: "POST",
});
expect(response.status).toBe(200);
});
it("rejects a request with no Origin header at all", async () => {
const app = buildOriginApp();
const response = await app.request("/mutate", { method: "POST" });
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("rejects a request from an untrusted origin", async () => {
const app = buildOriginApp();
const response = await app.request("/mutate", {
headers: { origin: "https://evil.example.test" },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
});
+375
View File
@@ -0,0 +1,375 @@
import {
createAuthenticatedPrincipal,
parseAuthorizationScope,
parseEkklesiaUserId,
parseGrantId,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
AuthorizationStoreError,
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_PERMISSION_REGISTRY,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { createGrantsRoutes } from "../src/grants-routes.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const manager = createAuthenticatedPrincipal("manager_1");
const subjectUserId = parseEkklesiaUserId("subject_1");
// Well-formed but never declared in `CORE_PERMISSION_REGISTRY` — used to prove the routes reject
// (or, for listing, still return) an unregistered permission id rather than assuming registration.
const permission = parsePermissionId("calendar.events.read");
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
// `store.createGrant`/`store.revokeGrant` can do so without also depending on module permission
// registration, which does not exist yet.
const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(manager),
...overrides,
};
}
function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationStore {
return {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue([]),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
...overrides,
};
}
function buildApp(options: {
readonly allowed?: boolean;
readonly runtime?: EkklesiaIdentityRuntime;
readonly store?: AuthorizationStore;
}) {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: options.allowed ?? true }),
};
const runtime = options.runtime ?? fakeRuntime();
const store = options.store ?? fakeStore();
const app = new Hono();
app.route(
"/",
createGrantsRoutes({
evaluator,
registry: CORE_PERMISSION_REGISTRY,
runtime,
store,
trustedOrigins: new Set([trustedOrigin]),
}),
);
return { app, evaluator, runtime, store };
}
describe("GET /api/v1/grants", () => {
it("denies an anonymous or non-manager authenticated caller (evaluator says no)", async () => {
const { app, evaluator } = buildApp({ allowed: false });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
expect(response.status).toBe(403);
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "restricted" }),
);
});
it("allows a caller the evaluator authorizes — whether by platform ownership or a delegated grants.manage grant, the route cannot tell and does not need to (see core/tests/authorization-evaluator.test.ts for that distinction)", async () => {
const grants = [
{
createdAt: new Date("2026-01-01T00:00:00.000Z"),
id: grantId,
permission,
sourceKind: "explicit" as const,
userId: subjectUserId,
},
];
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue(grants) });
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
const body = (await response.json()) as { grants: unknown[] };
expect(response.status).toBe(200);
expect(body.grants).toHaveLength(1);
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { sourceKind: "explicit" });
});
it("rejects a missing or malformed userId query parameter", async () => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants?userId=");
expect(response.status).toBe(400);
expect(store.listGrantsForUser).not.toHaveBeenCalled();
});
it("maps a store infrastructure failure to a sanitized 503", async () => {
const store = fakeStore({
listGrantsForUser: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_LIST_FAILED" });
});
});
describe("POST /api/v1/grants", () => {
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
const { app, evaluator } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it("rejects an untrusted Origin header the same way", async () => {
const { app } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("denies a caller the evaluator does not authorize", async () => {
const { app, store } = buildApp({ allowed: false });
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(403);
expect(store.createGrant).not.toHaveBeenCalled();
});
it("creates an explicit grant, deriving sourceKind and actor server-side from the authenticated principal, never from the request body", async () => {
const createdGrant = {
createdAt: new Date("2026-01-01T00:00:00.000Z"),
id: grantId,
permission: registeredPermission,
scope: parseAuthorizationScope("calendar-event:123"),
sourceKind: "explicit" as const,
userId: subjectUserId,
};
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
const { app } = buildApp({ store });
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({
permission: registeredPermission,
scope: "calendar-event:123",
subjectUserId,
}),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
const body = (await response.json()) as { grant: { id: string } };
expect(response.status).toBe(201);
expect(body.grant.id).toBe(grantId);
expect(store.createGrant).toHaveBeenCalledWith(
expect.objectContaining({
actor: { type: "user", userId: manager.userId },
permission: registeredPermission,
scope: "calendar-event:123",
sourceKind: "explicit",
userId: subjectUserId,
}),
);
});
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it.each([
"actor",
"owner",
"isOwner",
"sourceKind",
"sourceRef",
"id",
"grantId",
"createdAt",
"revokedAt",
"createdByActorId",
"effectivePermissions",
])("rejects a request body carrying a disallowed field (%s), even alongside otherwise-valid fields", async (field) => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ [field]: "hostile-value", permission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(400);
expect(store.createGrant).not.toHaveBeenCalled();
});
it("rejects a malformed permission, subject id, scope, or expiresAt", async () => {
const { app, store } = buildApp({});
const responses = await Promise.all(
[
{ permission: "not-namespaced", subjectUserId },
{ permission, subjectUserId: "" },
{ permission, scope: "", subjectUserId },
{ expiresAt: "not-a-date", permission, subjectUserId },
].map((body) =>
app.request("/api/v1/grants", {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
}),
),
);
for (const response of responses) {
expect(response.status).toBe(400);
}
expect(store.createGrant).not.toHaveBeenCalled();
});
it("maps a store infrastructure failure to a sanitized 503", async () => {
const store = fakeStore({
createGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED")),
});
const { app } = buildApp({ store });
const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
});
});
describe("POST /api/v1/grants/:grantId/revoke", () => {
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
const { app, evaluator } = buildApp({});
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { method: "POST" });
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(evaluator.authorize).not.toHaveBeenCalled();
});
it("revokes an explicit grant, deriving the actor server-side from the authenticated principal", async () => {
const store = fakeStore({ revokeGrant: vi.fn().mockResolvedValue(undefined) });
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true });
expect(store.revokeGrant).toHaveBeenCalledWith({
actor: { type: "user", userId: manager.userId },
grantId,
sourceKind: "explicit",
});
});
it("always constrains revocation to sourceKind explicit, so a relationship-derived grant can never be targeted by id", async () => {
// The store itself enforces this by treating a sourceKind mismatch exactly like a missing
// grant; this test proves the route always asks for that constraint, and correctly surfaces
// the resulting "not found" as 404 rather than leaking that the grant exists but is the wrong
// kind.
const store = fakeStore({
revokeGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
expect(store.revokeGrant).toHaveBeenCalledWith(
expect.objectContaining({ sourceKind: "explicit" }),
);
});
it("rejects a malformed grant id", async () => {
const { app, store } = buildApp({});
const response = await app.request("/api/v1/grants/%20/revoke", {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(400);
expect(store.revokeGrant).not.toHaveBeenCalled();
});
it("maps a store infrastructure failure to a sanitized 503", async () => {
const store = fakeStore({
revokeGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
});
});
+22
View File
@@ -176,6 +176,26 @@ describe("Ekklesia API shutdown lifecycle", () => {
expect(poolEnd).toHaveBeenCalledTimes(1); expect(poolEnd).toHaveBeenCalledTimes(1);
}); });
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
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());
// Identity (via Better Auth's Drizzle adapter) and authorization both read from the same
// running API, yet only one `new Pool(...)` call is ever made — proving the shared-pool
// composition in apps/api/src/database.ts never falls back to a second, independent pool.
expect(poolConstructor).toHaveBeenCalledTimes(1);
await running.shutdown();
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => { it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
@@ -205,6 +225,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
code: "IDENTITY_CONSTRUCTION_FAILED", code: "IDENTITY_CONSTRUCTION_FAILED",
}); });
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1); expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled(); expect(serveMock).not.toHaveBeenCalled();
}); });
@@ -219,6 +240,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
await expect(startApi(validConfig())).rejects.toThrow(); await expect(startApi(validConfig())).rejects.toThrow();
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1); expect(poolEnd).toHaveBeenCalledTimes(1);
}); });
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit";
export default defineConfig({ export default defineConfig({
dialect: "postgresql", dialect: "postgresql",
schema: "./src/identity/schema.ts", schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"],
out: "./drizzle", out: "./drizzle",
migrations: { migrations: {
prefix: "index", prefix: "index",
@@ -0,0 +1,51 @@
CREATE TABLE "ekklesia_core_authorization_audit" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"action" text NOT NULL,
"actor_type" text NOT NULL,
"actor_id" text,
"subject_user_id" text NOT NULL,
"permission_id" text,
"scope" text,
"grant_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_authorization_audit_action_check" CHECK ("ekklesia_core_authorization_audit"."action" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')),
CONSTRAINT "ekklesia_core_authorization_audit_actor_type_check" CHECK ("ekklesia_core_authorization_audit"."actor_type" in ('user', 'system')),
CONSTRAINT "ekklesia_core_authorization_audit_scope_check" CHECK ("ekklesia_core_authorization_audit"."scope" is null or length("ekklesia_core_authorization_audit"."scope") > 0)
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_grant" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"permission_id" text NOT NULL,
"scope" text,
"source_kind" text NOT NULL,
"source_ref" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_by_actor_type" text NOT NULL,
"created_by_actor_id" text,
"expires_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"revoked_by_actor_type" text,
"revoked_by_actor_id" text,
CONSTRAINT "ekklesia_core_grant_source_kind_check" CHECK ("ekklesia_core_grant"."source_kind" in ('explicit', 'relationship')),
CONSTRAINT "ekklesia_core_grant_created_actor_type_check" CHECK ("ekklesia_core_grant"."created_by_actor_type" in ('user', 'system')),
CONSTRAINT "ekklesia_core_grant_revoked_actor_type_check" CHECK ("ekklesia_core_grant"."revoked_by_actor_type" is null or "ekklesia_core_grant"."revoked_by_actor_type" in ('user', 'system'))
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_platform_owner" (
"user_id" text PRIMARY KEY NOT NULL,
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
"granted_by_actor_type" text NOT NULL,
"granted_by_actor_id" text,
"revoked_at" timestamp with time zone,
"revoked_by_actor_type" text,
"revoked_by_actor_id" text,
CONSTRAINT "ekklesia_core_platform_owner_granted_actor_type_check" CHECK ("ekklesia_core_platform_owner"."granted_by_actor_type" in ('user', 'system')),
CONSTRAINT "ekklesia_core_platform_owner_revoked_actor_type_check" CHECK ("ekklesia_core_platform_owner"."revoked_by_actor_type" is null or "ekklesia_core_platform_owner"."revoked_by_actor_type" in ('user', 'system'))
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_authorization_audit" ADD CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_grant" ADD CONSTRAINT "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_platform_owner" ADD CONSTRAINT "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ekklesia_core_authorization_audit_subject_idx" ON "ekklesia_core_authorization_audit" USING btree ("subject_user_id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_grant_user_permission_idx" ON "ekklesia_core_grant" USING btree ("user_id","permission_id");
+683
View File
@@ -0,0 +1,683 @@
{
"id": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
"prevId": "42cf39d2-7833-4bf3-9043-4e5303da8a75",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_authorization_audit",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["subject_user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+6
View File
@@ -7,6 +7,12 @@
"destructive": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0001_authorization_core_tables",
"destructive": {
"mode": "none"
}
} }
] ]
} }
+7
View File
@@ -8,6 +8,13 @@
"when": 1784668552522, "when": 1784668552522,
"tag": "0000_identity_core_tables", "tag": "0000_identity_core_tables",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1784819635300,
"tag": "0001_authorization_core_tables",
"breakpoints": true
} }
] ]
} }
+8
View File
@@ -26,6 +26,14 @@
"./identity": { "./identity": {
"types": "./dist/identity/index.d.ts", "types": "./dist/identity/index.d.ts",
"import": "./dist/identity/index.js" "import": "./dist/identity/index.js"
},
"./authorization": {
"types": "./dist/authorization/index.d.ts",
"import": "./dist/authorization/index.js"
},
"./database": {
"types": "./dist/database/index.d.ts",
"import": "./dist/database/index.js"
} }
}, },
"scripts": { "scripts": {
+115
View File
@@ -0,0 +1,115 @@
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { eq, sql } from "drizzle-orm";
import { ekklesiaCoreUser } from "../identity/schema.js";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import { ekklesiaCoreAuthorizationAudit, ekklesiaCorePlatformOwner } from "./schema.js";
export type PlatformOwnerBootstrapErrorCode =
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
| "PLATFORM_OWNER_BOOTSTRAP_FAILED"
| "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND";
const bootstrapErrorMessages: Readonly<Record<PlatformOwnerBootstrapErrorCode, string>> =
Object.freeze({
PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED:
"Platform-owner bootstrap has already been used and cannot be used again.",
PLATFORM_OWNER_BOOTSTRAP_FAILED: "Platform-owner bootstrap could not be completed.",
PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND: "The target user does not exist.",
});
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
export class PlatformOwnerBootstrapError extends Error {
readonly code: PlatformOwnerBootstrapErrorCode;
constructor(code: PlatformOwnerBootstrapErrorCode) {
super(bootstrapErrorMessages[code]);
this.name = "PlatformOwnerBootstrapError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function isPlatformOwnerBootstrapError(error: unknown): error is PlatformOwnerBootstrapError {
return error instanceof PlatformOwnerBootstrapError;
}
/** A fixed, sanitized, non-user actor identity for the audit trail — distinguishable from both an
* ordinary "system" action with no specific actor and from any real `EkklesiaUserId`, so an
* auditor can tell this owner grant came specifically from the bootstrap command. Never a real
* user id and never derived from caller input. */
const BOOTSTRAP_ACTOR_TYPE = "system";
const BOOTSTRAP_ACTOR_ID = "authorization-bootstrap-cli";
// A dedicated advisory-lock key pair, distinct from `core/src/migrations/postgres-database.ts`'s
// migration lock, so bootstrap serialization can never collide with or be blocked by unrelated
// migration locking. `pg_advisory_xact_lock` is transaction-scoped: it is released automatically
// on COMMIT or ROLLBACK, so no manual unlock is needed even if the transaction fails partway.
const bootstrapLockNamespace = 1_500_000_001;
const bootstrapLockKey = 1_500_000_002;
export interface BootstrapPlatformOwnerInput {
readonly userId: EkklesiaUserId;
}
/**
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
* composition of "check, then call the existing grant function": the single-use check, the target
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first
* statement — so two concurrent callers can never both observe "no owner yet" and both insert.
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
* re-evaluates against the now-current state.
*
* "Already consumed" is global and permanent: it is true the moment
* `ekklesia_core_platform_owner` has ever held any row for any user, including one whose
* `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a
* platform owner would ever be re-established after a revocation, not this command.
*/
export async function bootstrapPlatformOwner(
db: AuthorizationDrizzleDatabase,
input: BootstrapPlatformOwnerInput,
): Promise<void> {
try {
await db.transaction(async (tx) => {
await tx.execute(
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
);
const existingOwnerRows = await tx
.select({ userId: ekklesiaCorePlatformOwner.userId })
.from(ekklesiaCorePlatformOwner)
.limit(1);
if (existingOwnerRows.length > 0) {
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
}
const targetUserRows = await tx
.select({ id: ekklesiaCoreUser.id })
.from(ekklesiaCoreUser)
.where(eq(ekklesiaCoreUser.id, input.userId))
.limit(1);
if (targetUserRows.length === 0) {
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
}
await tx.insert(ekklesiaCorePlatformOwner).values({
grantedByActorId: BOOTSTRAP_ACTOR_ID,
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
userId: input.userId,
});
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
action: "platform_owner.grant",
actorId: BOOTSTRAP_ACTOR_ID,
actorType: BOOTSTRAP_ACTOR_TYPE,
subjectUserId: input.userId,
});
});
} catch (error) {
if (isPlatformOwnerBootstrapError(error)) {
throw error;
}
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED");
}
}
+118
View File
@@ -0,0 +1,118 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool, type PoolConfig } from "pg";
import { authorizationSchema } from "./schema.js";
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
export type AuthorizationDatabaseErrorCode = "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID";
const maximumPostgresTimeoutMs = 2_147_483_647;
/** Never carries the offending connection string, host, or underlying driver error. */
export class AuthorizationDatabaseError extends Error {
readonly code: AuthorizationDatabaseErrorCode;
constructor(code: AuthorizationDatabaseErrorCode) {
super("Authorization database configuration is invalid.");
this.name = "AuthorizationDatabaseError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface AuthorizationDatabaseOptions {
readonly connectionTimeoutMs?: number;
/** Bounds both `query_timeout` and `statement_timeout`, mirroring
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
* connection, not a hung query already running on one. */
readonly operationTimeoutMs?: number;
}
export interface AuthorizationDatabaseHandle {
readonly close: () => Promise<void>;
readonly db: AuthorizationDrizzleDatabase;
}
function validateConnectionString(connectionString: string): string {
if (connectionString.trim().length === 0) {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
let protocol: string;
try {
protocol = new URL(connectionString).protocol;
} catch {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
if (protocol !== "postgres:" && protocol !== "postgresql:") {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
return connectionString;
}
/** Mirrors `core/src/identity/database.ts`'s timeout validation exactly: an explicit timeout must
* be a positive safe integer no larger than PostgreSQL's own maximum, or it is rejected as invalid
* configuration rather than silently passed through to `pg`. */
function validateTimeout(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value <= 0 ||
value > maximumPostgresTimeoutMs
) {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
return value;
}
/**
* Explicitly constructs the Core-owned Drizzle database instance backing the authorization store.
* Building the `pg.Pool` performs no network I/O until a query is issued, and this function is
* only ever invoked by explicit caller code — never during module import.
*/
export function createAuthorizationDrizzleDatabase(
connectionString: string,
options: AuthorizationDatabaseOptions = {},
): AuthorizationDatabaseHandle {
const validatedConnectionString = validateConnectionString(connectionString);
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
const poolConfig: PoolConfig = {
connectionString: validatedConnectionString,
...(validatedConnectionTimeoutMs === undefined
? {}
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
...(validatedOperationTimeoutMs === undefined
? {}
: {
query_timeout: validatedOperationTimeoutMs,
statement_timeout: validatedOperationTimeoutMs,
}),
};
const pool = new Pool(poolConfig);
// Same rationale as core/src/identity/database.ts: pg's own documentation requires an error
// listener on the pool, or a server-initiated termination of an idle client becomes an
// unhandled Node.js error and can crash the process.
pool.on("error", () => undefined);
const db = drizzle(pool, { schema: authorizationSchema });
return Object.freeze({
close: () => pool.end(),
db,
});
}
/**
* A typed Drizzle view over an externally owned pool — for example one shared with the identity
* schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike
* `createAuthorizationDrizzleDatabase`, this function never constructs, registers an error
* listener on, or closes a pool itself: the caller retains that responsibility exactly once,
* regardless of how many typed views it creates over the same pool.
*/
export function createAuthorizationDrizzleView(pool: Pool): AuthorizationDrizzleDatabase {
return drizzle(pool, { schema: authorizationSchema });
}
+143
View File
@@ -0,0 +1,143 @@
import {
AUTHORIZATION_ALLOWED,
AUTHORIZATION_DENIED,
type AuthorizationDecision,
type AuthorizationScope,
type EkklesiaPrincipal,
isAuthenticatedPrincipal,
permissionBaselineSchema,
safeParseAuthorizationScope,
safeParsePermissionId,
} from "@ekklesia/contracts";
import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js";
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
/** Never carries the underlying port error, database error, cause, stack, or any other internal
* detail — defense in depth on top of whatever sanitization the injected ports themselves already
* perform (`core/src/authorization/store.ts` already sanitizes), exactly like
* `core/src/identity/runtime.ts`'s `resolvePrincipal` re-sanitizes on top of Better Auth's own
* error handling rather than trusting it exclusively. */
export class AuthorizationEvaluationError extends Error {
readonly code: AuthorizationEvaluationErrorCode;
constructor() {
super("The authorization decision could not be evaluated.");
this.name = "AuthorizationEvaluationError";
this.code = "AUTHORIZATION_EVALUATION_FAILED";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface AuthorizeRequest {
readonly principal: EkklesiaPrincipal;
/** Validated internally via the branded `PermissionId` schema *before any baseline can return an
* allowed decision* — a malformed or unrecognizable value denies rather than throwing, for
* `public` and `authenticated` baselines exactly as much as `restricted`. This is shape
* validation only: the evaluator has no registry and cannot tell a well-formed-but-unregistered
* permission from one that is actually registered. Only a trusted caller consulting a
* `PermissionRegistry` (`core/src/authorization/permissions.ts`) before calling `authorize` can
* make that distinction — see that module and `apps/api/src/authorization-middleware.ts`. */
readonly permission: unknown;
/** Validated internally against the known `public`/`authenticated`/`restricted` set; anything
* else denies rather than throwing, for the same fail-closed reason. */
readonly baseline: unknown;
/** Optional; validated internally when present. Absent means an unscoped check. */
readonly scope?: unknown;
}
export interface AuthorizationEvaluator {
authorize(request: AuthorizeRequest): Promise<AuthorizationDecision>;
}
export interface CreateAuthorizationEvaluatorOptions {
readonly grants: AuthorizationGrantPort;
readonly platformOwners: AuthorizationPlatformOwnerPort;
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
* real wall clock; production composition must leave this unset. */
readonly now?: () => Date;
}
/**
* Core's Hono-independent, deny-by-default authorization evaluator. It is the only place
* Ekklesia decides whether a principal may exercise a permission, and it never trusts anything
* the caller supplies beyond `principal`/`permission`/`baseline`/`scope` — there is no field for a
* caller to assert "I am the owner" or "I already have this grant." Owner and grant state are
* always re-derived from the injected ports for every call.
*
* A genuine infrastructure failure from either port (for example a database outage) is
* deliberately NOT swallowed into a `denied` decision here: it propagates to the caller as a
* thrown, sanitized `AuthorizationEvaluationError`, exactly like Core's identity
* `resolvePrincipal` never silently downgrades an outage to "anonymous." Only a genuinely
* malformed or unrecognized permission/baseline/scope — a deterministic, expected input problem,
* not an infrastructure failure — resolves to `denied` without throwing.
*/
export function createAuthorizationEvaluator(
options: CreateAuthorizationEvaluatorOptions,
): AuthorizationEvaluator {
const now = options.now ?? (() => new Date());
return Object.freeze({
authorize: async (request: AuthorizeRequest): Promise<AuthorizationDecision> => {
const parsedBaseline = permissionBaselineSchema.safeParse(request.baseline);
if (!parsedBaseline.success) {
return AUTHORIZATION_DENIED;
}
// Shape-validated before any baseline is allowed to return `allowed` — a malformed
// permission id must deny under `public` and `authenticated` exactly as much as under
// `restricted`, even though neither of the first two ever reads `parsedPermission.data`.
// This is a pure, synchronous check: it never touches the grant or platform-owner ports, so
// `public`/`authenticated` still resolve with no database lookup at all.
const parsedPermission = safeParsePermissionId(request.permission);
if (!parsedPermission.success) {
return AUTHORIZATION_DENIED;
}
if (parsedBaseline.data === "public") {
return AUTHORIZATION_ALLOWED;
}
if (parsedBaseline.data === "authenticated") {
return isAuthenticatedPrincipal(request.principal)
? AUTHORIZATION_ALLOWED
: AUTHORIZATION_DENIED;
}
// parsedBaseline.data === "restricted": requires an authenticated principal AND
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
if (!isAuthenticatedPrincipal(request.principal)) {
return AUTHORIZATION_DENIED;
}
let scope: AuthorizationScope | undefined;
if (request.scope !== undefined) {
const parsedScope = safeParseAuthorizationScope(request.scope);
if (!parsedScope.success) {
return AUTHORIZATION_DENIED;
}
scope = parsedScope.data;
}
const userId = request.principal.userId;
let isOwner: boolean;
let hasGrant: boolean;
try {
[isOwner, hasGrant] = await Promise.all([
options.platformOwners.isPlatformOwner(userId),
options.grants.hasActiveGrant({
now: now(),
permission: parsedPermission.data,
userId,
...(scope === undefined ? {} : { scope }),
}),
]);
} catch {
throw new AuthorizationEvaluationError();
}
return isOwner || hasGrant ? AUTHORIZATION_ALLOWED : AUTHORIZATION_DENIED;
},
});
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./bootstrap.js";
export * from "./database.js";
export * from "./evaluator.js";
export * from "./permissions.js";
export * from "./ports.js";
export * from "./store.js";
+109
View File
@@ -0,0 +1,109 @@
import {
type PermissionBaseline,
type PermissionId,
parsePermissionId,
safeParsePermissionId,
} from "@ekklesia/contracts";
/**
* A registered permission declaration: the trusted pairing of a permission identifier with its
* one baseline. This is what distinguishes a *registered* permission from a merely well-formed
* `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an
* explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller
* (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that
* baseline is never client-supplied or guessed.
*/
export interface PermissionDeclaration {
readonly id: PermissionId;
readonly baseline: PermissionBaseline;
/** Human-readable, for operators and audit review only — never interpreted by the evaluator or
* by enforcement. */
readonly description: string;
}
export interface PermissionRegistry {
readonly declarations: readonly PermissionDeclaration[];
/** Returns the declaration for a permission id, or `undefined` for anything malformed or not
* registered — the two are deliberately indistinguishable to callers, so "unknown permission"
* and "invalid permission" fail closed the same way. */
get(permissionId: unknown): PermissionDeclaration | undefined;
has(permissionId: unknown): boolean;
}
export type PermissionRegistryErrorCode = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
/** Thrown only at composition time (registry construction), never per-request. Carries the
* offending permission id deliberately — this is a startup-time programmer/configuration error
* message, not a runtime response, and a namespaced permission id is not sensitive. */
export class PermissionRegistryError extends Error {
readonly code: PermissionRegistryErrorCode;
readonly permissionId: string;
constructor(permissionId: string) {
super(`Permission "${permissionId}" is already registered.`);
this.name = "PermissionRegistryError";
this.code = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
this.permissionId = permissionId;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Builds an immutable permission registry from a fixed list of declarations, rejecting any
* duplicate permission id outright at composition time. Deliberately the smallest mechanism that
* distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no
* network or filesystem I/O — a future module registration mechanism can merge its own
* declarations into a combined list and still call this same constructor, inheriting duplicate
* detection for free instead of reimplementing it.
*/
export function createPermissionRegistry(
declarations: readonly PermissionDeclaration[],
): PermissionRegistry {
const byId = new Map<PermissionId, PermissionDeclaration>();
for (const declaration of declarations) {
if (byId.has(declaration.id)) {
throw new PermissionRegistryError(declaration.id);
}
byId.set(declaration.id, Object.freeze({ ...declaration }));
}
const frozenDeclarations = Object.freeze([...byId.values()]);
return Object.freeze({
declarations: frozenDeclarations,
get(permissionId: unknown): PermissionDeclaration | undefined {
const parsed = safeParsePermissionId(permissionId);
return parsed.success ? byId.get(parsed.data) : undefined;
},
has(permissionId: unknown): boolean {
const parsed = safeParsePermissionId(permissionId);
return parsed.success && byId.has(parsed.data);
},
});
}
/** Restricted: delegated grant administration is itself a privileged action, never public or
* merely authenticated. A platform owner always passes the evaluator's existing owner bypass and
* can delegate this permission to another account deliberately via the explicit-grant API. */
export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
"core.authorization.grants.manage",
);
const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
Object.freeze({
baseline: "restricted" as const,
description: "Create, list, and revoke another user's explicit authorization grants.",
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
}),
]);
/**
* Core's own permission registry — the trusted source of truth `apps/api`'s authorization
* enforcement consults so an HTTP route can never supply its own baseline. Contains exactly the
* one Core-owned permission this slice needs; a future module registration mechanism is
* deliberately out of scope here and will compose its own declarations through
* `createPermissionRegistry` rather than through this constant.
*/
export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry(
CORE_PERMISSION_DECLARATIONS,
);
+28
View File
@@ -0,0 +1,28 @@
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
export interface ActiveGrantQuery {
readonly userId: EkklesiaUserId;
readonly permission: PermissionId;
/** Absent means an unscoped check. An unscoped grant satisfies either an unscoped or scoped
* check for the same permission; a scoped grant satisfies only the exact matching scope and
* never satisfies an unscoped check. */
readonly scope?: AuthorizationScope;
/** The instant to evaluate expiration against — injected so evaluation is deterministic and
* testable rather than reading the wall clock inside the lookup itself. */
readonly now: Date;
}
/**
* The only read dependency `createAuthorizationEvaluator` has for grant state. Narrow and
* Postgres-independent by design: a test can supply a fake implementation, and the evaluator
* itself never knows whether it is backed by `core/src/authorization/store.ts` or a test double.
*/
export interface AuthorizationGrantPort {
hasActiveGrant(query: ActiveGrantQuery): Promise<boolean>;
}
/** The only read dependency the evaluator has for platform-owner authority — deliberately a
* separate, narrower port from grants, since owner authority is not represented as a grant. */
export interface AuthorizationPlatformOwnerPort {
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
}
+145
View File
@@ -0,0 +1,145 @@
import { relations, sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { ekklesiaCoreUser } from "../identity/schema.js";
/**
* Core-owned authorization schema: grants (explicit or relationship-derived), platform-owner
* status, and an append-only audit log of every security-relevant mutation. Table and index names
* are explicit and namespaced so ownership is unambiguous inside the shared public PostgreSQL
* schema, exactly like `core/src/identity/schema.ts`. Foreign keys to `ekklesia_core_user` are
* safe here (unlike a module referencing another module's tables) because Core owns both sides.
*/
export const ekklesiaCoreGrant = pgTable(
"ekklesia_core_grant",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id")
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
permissionId: text("permission_id").notNull(),
scope: text("scope"),
sourceKind: text("source_kind").notNull(),
sourceRef: text("source_ref"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
createdByActorType: text("created_by_actor_type").notNull(),
createdByActorId: text("created_by_actor_id"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByActorType: text("revoked_by_actor_type"),
revokedByActorId: text("revoked_by_actor_id"),
},
(table) => [
index("ekklesia_core_grant_user_permission_idx").on(table.userId, table.permissionId),
check(
"ekklesia_core_grant_source_kind_check",
sql`${table.sourceKind} in ('explicit', 'relationship')`,
),
check(
"ekklesia_core_grant_created_actor_type_check",
sql`${table.createdByActorType} in ('user', 'system')`,
),
check(
"ekklesia_core_grant_revoked_actor_type_check",
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
),
],
);
export const ekklesiaCorePlatformOwner = pgTable(
"ekklesia_core_platform_owner",
{
userId: text("user_id")
.primaryKey()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
grantedByActorType: text("granted_by_actor_type").notNull(),
grantedByActorId: text("granted_by_actor_id"),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByActorType: text("revoked_by_actor_type"),
revokedByActorId: text("revoked_by_actor_id"),
},
(table) => [
check(
"ekklesia_core_platform_owner_granted_actor_type_check",
sql`${table.grantedByActorType} in ('user', 'system')`,
),
check(
"ekklesia_core_platform_owner_revoked_actor_type_check",
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
),
],
);
/**
* Append-only: no update or delete path is ever exposed by Core's authorization store. `scope`
* intentionally rejects an empty (but non-null) string — defense in depth matching the
* application-layer `AuthorizationScope` contract, which never produces one — so a coding defect
* that slipped an empty scope past that validation fails the whole grant-plus-audit transaction
* atomically instead of recording a meaningless audit entry.
*/
export const ekklesiaCoreAuthorizationAudit = pgTable(
"ekklesia_core_authorization_audit",
{
id: uuid("id").primaryKey().defaultRandom(),
action: text("action").notNull(),
actorType: text("actor_type").notNull(),
actorId: text("actor_id"),
subjectUserId: text("subject_user_id")
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
permissionId: text("permission_id"),
scope: text("scope"),
grantId: uuid("grant_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("ekklesia_core_authorization_audit_subject_idx").on(table.subjectUserId),
check(
"ekklesia_core_authorization_audit_action_check",
sql`${table.action} in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')`,
),
check(
"ekklesia_core_authorization_audit_actor_type_check",
sql`${table.actorType} in ('user', 'system')`,
),
check(
"ekklesia_core_authorization_audit_scope_check",
sql`${table.scope} is null or length(${table.scope}) > 0`,
),
],
);
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreGrant.userId],
references: [ekklesiaCoreUser.id],
}),
}));
export const ekklesiaCorePlatformOwnerRelations = relations(
ekklesiaCorePlatformOwner,
({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCorePlatformOwner.userId],
references: [ekklesiaCoreUser.id],
}),
}),
);
export const ekklesiaCoreAuthorizationAuditRelations = relations(
ekklesiaCoreAuthorizationAudit,
({ one }) => ({
subject: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreAuthorizationAudit.subjectUserId],
references: [ekklesiaCoreUser.id],
}),
}),
);
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
export const authorizationSchema = {
authorizationAudit: ekklesiaCoreAuthorizationAudit,
grant: ekklesiaCoreGrant,
platformOwner: ekklesiaCorePlatformOwner,
} as const;
+389
View File
@@ -0,0 +1,389 @@
import type {
AuthorizationActor,
AuthorizationScope,
EkklesiaUserId,
GrantId,
GrantSourceKind,
GrantSourceRef,
PermissionId,
} from "@ekklesia/contracts";
import {
isUserActor,
parseAuthorizationScope,
parseEkklesiaUserId,
parseGrantId,
parsePermissionId,
} from "@ekklesia/contracts";
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import type {
ActiveGrantQuery,
AuthorizationGrantPort,
AuthorizationPlatformOwnerPort,
} from "./ports.js";
import {
ekklesiaCoreAuthorizationAudit,
ekklesiaCoreGrant,
ekklesiaCorePlatformOwner,
} from "./schema.js";
export type AuthorizationStoreErrorCode =
| "AUTHORIZATION_GRANT_CREATE_FAILED"
| "AUTHORIZATION_GRANT_LIST_FAILED"
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
| "AUTHORIZATION_GRANT_NOT_FOUND"
| "AUTHORIZATION_GRANT_REVOKE_FAILED"
| "AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED"
| "AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED"
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
AUTHORIZATION_GRANT_REVOKE_FAILED: "The authorization grant could not be revoked.",
AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED: "The platform-owner lookup could not be completed.",
AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED: "The platform-owner change could not be recorded.",
AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND:
"The platform-owner record was not found or is already revoked.",
});
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
export class AuthorizationStoreError extends Error {
readonly code: AuthorizationStoreErrorCode;
constructor(code: AuthorizationStoreErrorCode) {
super(storeErrorMessages[code]);
this.name = "AuthorizationStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function isAuthorizationStoreError(error: unknown): error is AuthorizationStoreError {
return error instanceof AuthorizationStoreError;
}
export interface CreateGrantInput {
readonly userId: EkklesiaUserId;
readonly permission: PermissionId;
readonly scope?: AuthorizationScope;
readonly sourceKind: GrantSourceKind;
readonly sourceRef?: GrantSourceRef;
readonly actor: AuthorizationActor;
/** Absent means the grant never expires. */
readonly expiresAt?: Date;
}
export interface GrantRecord {
readonly id: GrantId;
readonly userId: EkklesiaUserId;
readonly permission: PermissionId;
readonly scope?: AuthorizationScope;
readonly sourceKind: GrantSourceKind;
readonly sourceRef?: GrantSourceRef;
readonly createdAt: Date;
readonly expiresAt?: Date;
readonly revokedAt?: Date;
}
export interface RevokeGrantInput {
readonly grantId: GrantId;
readonly actor: AuthorizationActor;
/** When provided, the revoke only matches a grant whose `sourceKind` equals this value — a
* grant of a different source kind is treated exactly like a nonexistent one
* (`AUTHORIZATION_GRANT_NOT_FOUND`). The generic explicit-grant HTTP API always passes
* `"explicit"` here, so it can never revoke a relationship-derived grant regardless of caller
* intent; internal Core callers that omit this may revoke any grant, matching pass 1's
* original, unconstrained behavior. */
readonly sourceKind?: GrantSourceKind;
}
export interface ListGrantsForUserOptions {
/** When provided, only grants with this exact `sourceKind` are returned. */
readonly sourceKind?: GrantSourceKind;
}
/**
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
* append-only audit log, all behind one explicit factory — no environment reads, no connection
* attempt, and no query merely from importing this module or calling this factory; every method
* call is the only thing that ever touches PostgreSQL. Implements the narrow read ports
* `createAuthorizationEvaluator` depends on directly, so the same store instance is both Core's
* write API and the evaluator's read dependency.
*/
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
revokeGrant(input: RevokeGrantInput): Promise<void>;
/** Every grant ever recorded for the user, active or revoked, newest first — the caller decides
* what to display. Never includes another user's grants. */
listGrantsForUser(
userId: EkklesiaUserId,
options?: ListGrantsForUserOptions,
): Promise<readonly GrantRecord[]>;
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
}
function actorId(actor: AuthorizationActor): string | null {
return isUserActor(actor) ? actor.userId : null;
}
interface GrantRow {
readonly id: string;
readonly userId: string;
readonly permissionId: string;
readonly scope: string | null;
readonly sourceKind: string;
readonly sourceRef: string | null;
readonly createdAt: Date;
readonly expiresAt: Date | null;
readonly revokedAt: Date | null;
}
/** Re-validates a row this same store just wrote, rather than trusting raw column values by
* construction — defense in depth against a future schema/migration drift, matching the
* paranoid-by-default posture the rest of Core's persistence code already follows. */
function toGrantRecord(row: GrantRow): GrantRecord {
return Object.freeze({
createdAt: row.createdAt,
id: parseGrantId(row.id),
permission: parsePermissionId(row.permissionId),
...(row.scope === null ? {} : { scope: parseAuthorizationScope(row.scope) }),
...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt }),
...(row.revokedAt === null ? {} : { revokedAt: row.revokedAt }),
sourceKind: row.sourceKind as GrantSourceKind,
...(row.sourceRef === null ? {} : { sourceRef: row.sourceRef }),
userId: parseEkklesiaUserId(row.userId),
});
}
export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): AuthorizationStore {
return Object.freeze({
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
try {
return await db.transaction(async (tx) => {
const [row] = await tx
.insert(ekklesiaCoreGrant)
.values({
createdByActorId: actorId(input.actor),
createdByActorType: input.actor.type,
expiresAt: input.expiresAt ?? null,
permissionId: input.permission,
scope: input.scope ?? null,
sourceKind: input.sourceKind,
sourceRef: input.sourceRef ?? null,
userId: input.userId,
})
.returning();
if (!row) {
throw new Error("grant insert returned no row");
}
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
action: "grant.create",
actorId: actorId(input.actor),
actorType: input.actor.type,
grantId: row.id,
permissionId: input.permission,
scope: input.scope ?? null,
subjectUserId: input.userId,
});
return toGrantRecord(row);
});
} catch (error) {
if (isAuthorizationStoreError(error)) {
throw error;
}
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED");
}
},
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
try {
await db.transaction(async (tx) => {
const [row] = await tx
.update(ekklesiaCoreGrant)
.set({
revokedAt: sql`now()`,
revokedByActorId: actorId(input.actor),
revokedByActorType: input.actor.type,
})
.where(
and(
eq(ekklesiaCoreGrant.id, input.grantId),
isNull(ekklesiaCoreGrant.revokedAt),
...(input.sourceKind === undefined
? []
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
),
)
.returning();
if (!row) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
}
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
action: "grant.revoke",
actorId: actorId(input.actor),
actorType: input.actor.type,
grantId: row.id,
permissionId: row.permissionId,
scope: row.scope,
subjectUserId: row.userId,
});
});
} catch (error) {
if (isAuthorizationStoreError(error)) {
throw error;
}
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED");
}
},
hasActiveGrant: async (query: ActiveGrantQuery): Promise<boolean> => {
try {
const scopeCondition =
query.scope === undefined
? isNull(ekklesiaCoreGrant.scope)
: or(isNull(ekklesiaCoreGrant.scope), eq(ekklesiaCoreGrant.scope, query.scope));
const rows = await db
.select({ id: ekklesiaCoreGrant.id })
.from(ekklesiaCoreGrant)
.where(
and(
eq(ekklesiaCoreGrant.userId, query.userId),
eq(ekklesiaCoreGrant.permissionId, query.permission),
scopeCondition,
isNull(ekklesiaCoreGrant.revokedAt),
or(isNull(ekklesiaCoreGrant.expiresAt), gt(ekklesiaCoreGrant.expiresAt, query.now)),
),
)
.limit(1);
return rows.length > 0;
} catch {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LOOKUP_FAILED");
}
},
listGrantsForUser: async (
userId: EkklesiaUserId,
options: ListGrantsForUserOptions = {},
): Promise<readonly GrantRecord[]> => {
try {
const rows = await db
.select()
.from(ekklesiaCoreGrant)
.where(
and(
eq(ekklesiaCoreGrant.userId, userId),
...(options.sourceKind === undefined
? []
: [eq(ekklesiaCoreGrant.sourceKind, options.sourceKind)]),
),
)
.orderBy(desc(ekklesiaCoreGrant.createdAt));
return Object.freeze(rows.map(toGrantRecord));
} catch {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
}
},
grantPlatformOwner: async (
userId: EkklesiaUserId,
actor: AuthorizationActor,
): Promise<void> => {
try {
await db.transaction(async (tx) => {
await tx
.insert(ekklesiaCorePlatformOwner)
.values({
grantedByActorId: actorId(actor),
grantedByActorType: actor.type,
userId,
})
.onConflictDoUpdate({
set: {
grantedAt: sql`now()`,
grantedByActorId: actorId(actor),
grantedByActorType: actor.type,
revokedAt: null,
revokedByActorId: null,
revokedByActorType: null,
},
target: ekklesiaCorePlatformOwner.userId,
});
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
action: "platform_owner.grant",
actorId: actorId(actor),
actorType: actor.type,
subjectUserId: userId,
});
});
} catch {
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
}
},
revokePlatformOwner: async (
userId: EkklesiaUserId,
actor: AuthorizationActor,
): Promise<void> => {
try {
await db.transaction(async (tx) => {
const [row] = await tx
.update(ekklesiaCorePlatformOwner)
.set({
revokedAt: sql`now()`,
revokedByActorId: actorId(actor),
revokedByActorType: actor.type,
})
.where(
and(
eq(ekklesiaCorePlatformOwner.userId, userId),
isNull(ekklesiaCorePlatformOwner.revokedAt),
),
)
.returning();
if (!row) {
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND");
}
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
action: "platform_owner.revoke",
actorId: actorId(actor),
actorType: actor.type,
subjectUserId: userId,
});
});
} catch (error) {
if (isAuthorizationStoreError(error)) {
throw error;
}
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
}
},
isPlatformOwner: async (userId: EkklesiaUserId): Promise<boolean> => {
try {
const rows = await db
.select({ userId: ekklesiaCorePlatformOwner.userId })
.from(ekklesiaCorePlatformOwner)
.where(
and(
eq(ekklesiaCorePlatformOwner.userId, userId),
isNull(ekklesiaCorePlatformOwner.revokedAt),
),
)
.limit(1);
return rows.length > 0;
} catch {
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED");
}
},
});
}
+106
View File
@@ -0,0 +1,106 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
import {
bootstrapPlatformOwner,
createAuthorizationDrizzleDatabase,
PlatformOwnerBootstrapError,
} from "../authorization/index.js";
class BootstrapCliError extends Error {
constructor(message: string) {
super(message);
this.name = "BootstrapCliError";
}
}
const usage =
"Usage: pnpm authorization:bootstrap-owner -- --user-id <id> --confirm-user-id <same-id>";
interface ParsedArgs {
readonly confirmUserId: string;
readonly userId: string;
}
/** A minimal, hand-rolled flag parser — this command takes exactly two required, named flags, so
* a general-purpose argument-parsing dependency is not warranted. */
function parseArgs(argv: readonly string[]): ParsedArgs {
let userId: string | undefined;
let confirmUserId: string | undefined;
for (let index = 0; index < argv.length; index += 1) {
const flag = argv[index];
if (flag === "--user-id") {
userId = argv[index + 1];
index += 1;
} else if (flag === "--confirm-user-id") {
confirmUserId = argv[index + 1];
index += 1;
}
}
if (userId === undefined || confirmUserId === undefined) {
throw new BootstrapCliError(usage);
}
return { confirmUserId, userId };
}
async function main(): Promise<void> {
const { confirmUserId, userId: rawUserId } = parseArgs(process.argv.slice(2));
// Deliberately not "infer the first user" or "select by signup order" — the operator must name
// the exact, opaque Ekklesia user id twice, and both must match exactly, before anything else
// is even attempted.
if (rawUserId !== confirmUserId) {
throw new BootstrapCliError(
"--user-id and --confirm-user-id must match exactly. No changes were made.",
);
}
const parsedUserId = safeParseEkklesiaUserId(rawUserId);
if (!parsedUserId.success) {
throw new BootstrapCliError("The provided user id is not a valid Ekklesia user id.");
}
// Read only when this command actually executes — never merely by importing this module (see
// isDirectExecution() below) or any module it imports.
const databaseUrl = process.env.DATABASE_URL;
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
throw new BootstrapCliError("DATABASE_URL is required.");
}
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
});
try {
await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
console.log(`Platform-owner bootstrap complete for user "${parsedUserId.data}".`);
} finally {
await close().catch(() => undefined);
}
}
function reportBootstrapCliError(error: unknown): void {
if (error instanceof BootstrapCliError) {
console.error(error.message);
return;
}
if (error instanceof PlatformOwnerBootstrapError) {
console.error(`[${error.code}] ${error.message}`);
return;
}
console.error("Platform-owner bootstrap failed unexpectedly.");
}
function isDirectExecution(): boolean {
const entrypoint = process.argv[1];
return (
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
);
}
if (isDirectExecution()) {
main().catch((error: unknown) => {
reportBootstrapCliError(error);
process.exitCode = 1;
});
}
+1
View File
@@ -0,0 +1 @@
export * from "./pool.js";
+110
View File
@@ -0,0 +1,110 @@
import { Pool, type PoolConfig } from "pg";
export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID";
/** Never carries the offending connection string, host, or underlying driver error. */
export class PostgresPoolError extends Error {
readonly code: PostgresPoolErrorCode;
constructor(code: PostgresPoolErrorCode) {
super("PostgreSQL pool configuration is invalid.");
this.name = "PostgresPoolError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
const maximumPostgresTimeoutMs = 2_147_483_647;
export interface PostgresPoolOptions {
readonly connectionTimeoutMs?: number;
/** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds
* establishing a connection, not a hung query already running on one. */
readonly operationTimeoutMs?: number;
}
export interface PostgresPoolHandle {
readonly close: () => Promise<void>;
readonly pool: Pool;
}
function validateConnectionString(connectionString: string): string {
if (connectionString.trim().length === 0) {
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
}
let protocol: string;
try {
protocol = new URL(connectionString).protocol;
} catch {
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
}
if (protocol !== "postgres:" && protocol !== "postgresql:") {
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
}
return connectionString;
}
/** Mirrors the validation already used by `core/src/identity/database.ts`,
* `core/src/authorization/database.ts`, and `core/src/migrations/postgres-database.ts`: an
* explicit timeout must be a positive safe integer no larger than PostgreSQL's own maximum, or it
* is rejected as invalid configuration rather than silently passed through to `pg`. */
function validateTimeout(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value <= 0 ||
value > maximumPostgresTimeoutMs
) {
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
}
return value;
}
/**
* Ekklesia's one shared, validated `pg.Pool` constructor for a single physical database
* connection meant to back more than one typed Drizzle view (for example Core's identity and
* authorization schemas sharing one pool in `apps/api`) — one pool, one mandatory error listener,
* one `close()`. Building the pool performs no network I/O until a query is issued, and this
* function is only ever invoked by explicit caller code — never during module import.
*
* This does not replace `createIdentityDrizzleDatabase` or `createAuthorizationDrizzleDatabase`:
* those remain independent, self-contained factories (each owns a private pool and its own
* `close()`) for callers — tests included — that only need one domain. Use this function, plus
* `createIdentityDrizzleView`/`createAuthorizationDrizzleView`, specifically when more than one
* domain must share one physical connection pool.
*/
export function createValidatedPostgresPool(
connectionString: string,
options: PostgresPoolOptions = {},
): PostgresPoolHandle {
const validatedConnectionString = validateConnectionString(connectionString);
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
const poolConfig: PoolConfig = {
connectionString: validatedConnectionString,
...(validatedConnectionTimeoutMs === undefined
? {}
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
...(validatedOperationTimeoutMs === undefined
? {}
: {
query_timeout: validatedOperationTimeoutMs,
statement_timeout: validatedOperationTimeoutMs,
}),
};
const pool = new Pool(poolConfig);
// Same rationale as core/src/identity/database.ts: pg's own documentation requires an error
// listener on the pool, or a server-initiated termination of an idle client becomes an
// unhandled Node.js error and can crash the process.
pool.on("error", () => undefined);
return Object.freeze({
close: () => pool.end(),
pool,
});
}
+11
View File
@@ -108,3 +108,14 @@ export function createIdentityDrizzleDatabase(
db, db,
}); });
} }
/**
* A typed Drizzle view over an externally owned pool — for example one shared with the
* authorization schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike
* `createIdentityDrizzleDatabase`, this function never constructs, registers an error listener
* on, or closes a pool itself: the caller retains that responsibility exactly once, regardless of
* how many typed views it creates over the same pool.
*/
export function createIdentityDrizzleView(pool: Pool): IdentityDrizzleDatabase {
return drizzle(pool, { schema: identityAdapterSchema });
}
+2
View File
@@ -1,3 +1,5 @@
export * from "./authorization/index.js";
export * from "./database/index.js";
export * from "./diagnostics/index.js"; export * from "./diagnostics/index.js";
export * from "./identity/index.js"; export * from "./identity/index.js";
export * from "./modules/index.js"; export * from "./modules/index.js";
@@ -0,0 +1,364 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseEkklesiaUserId } from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
bootstrapPlatformOwner,
createAuthorizationDrizzleDatabase,
PlatformOwnerBootstrapError,
} from "../src/authorization/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The bootstrap integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The bootstrap integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 14);
databaseCounter += 1;
return `ekk_bootstrap_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure remains static when connection cleanup also fails.
}
throw harnessError("The bootstrap integration database connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function createDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(
path.join(tmpdir(), "ekklesia-bootstrap-no-modules-"),
);
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { recursive: true, force: true });
}
}
async function seedUser(client: Client, id: string, email: string): Promise<void> {
await safeQuery(
client,
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
values ($1, $2, $3, false, now(), now())`,
[id, "Integration Test User", email],
);
}
async function ownerRowCount(client: Client): Promise<number> {
const rows = await safeQuery<{ count: string }>(
client,
`select count(*)::text as count from "ekklesia_core_platform_owner"`,
);
return Number(rows[0]?.count ?? "0");
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
describe.sequential("live platform-owner bootstrap", () => {
let database: { name: string; url: string };
let admin: Client;
let db: ReturnType<typeof createAuthorizationDrizzleDatabase>["db"];
let close: () => Promise<void>;
const firstUserId = parseEkklesiaUserId("bootstrap_user_one");
const secondUserId = parseEkklesiaUserId("bootstrap_user_two");
it("bootstraps successfully for an existing user and records exactly one audit entry with the fixed bootstrap actor identity", async () => {
database = await createDisposableDatabase("success");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
await seedUser(admin, firstUserId, "[email protected]");
await seedUser(admin, secondUserId, "[email protected]");
const handle = createAuthorizationDrizzleDatabase(database.url);
db = handle.db;
close = handle.close;
await bootstrapPlatformOwner(db, { userId: firstUserId });
const owners = await safeQuery<{ user_id: string }>(
admin,
`select user_id from "ekklesia_core_platform_owner"`,
);
expect(owners).toEqual([{ user_id: firstUserId }]);
const audit = await safeQuery<{
action: string;
actor_id: string | null;
actor_type: string;
subject_user_id: string;
}>(
admin,
`select action, actor_id, actor_type, subject_user_id from "ekklesia_core_authorization_audit"`,
);
expect(audit).toEqual([
{
action: "platform_owner.grant",
actor_id: "authorization-bootstrap-cli",
actor_type: "system",
subject_user_id: firstUserId,
},
]);
});
it("rejects a second bootstrap attempt outright, even for a different user, and leaves no new row or audit entry", async () => {
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId: secondUserId });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError);
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
expect(await ownerRowCount(admin)).toBe(1);
const audit = await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`);
expect(audit).toHaveLength(1);
});
it("still refuses bootstrap after the existing owner is revoked — the historical row alone is enough to have consumed it", async () => {
await safeQuery(
admin,
`update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`,
[firstUserId],
);
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId: secondUserId });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError);
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
expect(await ownerRowCount(admin)).toBe(1);
});
it("shuts down cleanly", async () => {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
});
});
describe.sequential("live platform-owner bootstrap — nonexistent user", () => {
it("rolls back entirely for a user that does not exist, leaving no owner row and no audit entry", async () => {
const database = await createDisposableDatabase("missing_user");
await applyCoreMigrations(database.url);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
const missingUserId = parseEkklesiaUserId("bootstrap_user_never_seeded");
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId: missingUserId });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError);
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND" });
const admin = await openAdministrativeClient(database.url);
try {
expect(await ownerRowCount(admin)).toBe(0);
const audit = await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`);
expect(audit).toHaveLength(0);
} finally {
await safeEnd(admin);
}
} finally {
await close();
await dropDatabase(database.name);
}
});
});
describe.sequential("live platform-owner bootstrap — concurrency", () => {
it("produces exactly one owner when two bootstrap attempts race for two different users", async () => {
const database = await createDisposableDatabase("race");
await applyCoreMigrations(database.url);
const admin = await openAdministrativeClient(database.url);
const raceUserA = parseEkklesiaUserId("bootstrap_race_user_a");
const raceUserB = parseEkklesiaUserId("bootstrap_race_user_b");
await seedUser(admin, raceUserA, "[email protected]");
await seedUser(admin, raceUserB, "[email protected]");
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
const results = await Promise.allSettled([
bootstrapPlatformOwner(db, { userId: raceUserA }),
bootstrapPlatformOwner(db, { userId: raceUserB }),
]);
const fulfilled = results.filter((result) => result.status === "fulfilled");
const rejected = results.filter((result) => result.status === "rejected");
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
PlatformOwnerBootstrapError,
);
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({
code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED",
});
const owners = await safeQuery<{ user_id: string }>(
admin,
`select user_id from "ekklesia_core_platform_owner"`,
);
expect(owners).toHaveLength(1);
expect([raceUserA, raceUserB]).toContain(owners[0]?.user_id);
const audit = await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
);
expect(audit).toHaveLength(1);
} finally {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
}
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js";
import {
bootstrapPlatformOwner,
PlatformOwnerBootstrapError,
} from "../src/authorization/bootstrap.js";
describe("Core authorization bootstrap — import and construction safety", () => {
it("does not construct a pool or open a connection merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it("performs no database work merely by being imported: bootstrapPlatformOwner is only a function reference until called", () => {
expect(typeof bootstrapPlatformOwner).toBe("function");
expect(poolConstructor).not.toHaveBeenCalled();
});
});
describe("PlatformOwnerBootstrapError", () => {
it.each([
"PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED",
"PLATFORM_OWNER_BOOTSTRAP_FAILED",
"PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND",
] as const)("exposes a stable code and a fixed, generic message for %s", (code) => {
const error = new PlatformOwnerBootstrapError(code);
expect(error.code).toBe(code);
expect(error.name).toBe("PlatformOwnerBootstrapError");
expect(Object.isFrozen(error)).toBe(true);
expect(error.message.length).toBeGreaterThan(0);
});
it("never carries a cause, raw driver error, SQL, or stack detail beyond the fixed message", () => {
const error = new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED");
const publicText = `${String(error)}\n${error.stack ?? ""}`;
expect(publicText).toBe(
`PlatformOwnerBootstrapError: ${error.message}\nPlatformOwnerBootstrapError: ${error.message}`,
);
});
});
describe("bootstrapPlatformOwner — construction without a real database", () => {
it("does not construct a pool merely because a database handle was created; only calling bootstrapPlatformOwner would query", () => {
poolConstructor.mockClear();
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(db).toBeDefined();
// bootstrapPlatformOwner is intentionally not invoked here: exercising the real transactional
// and advisory-lock behavior requires a real PostgreSQL server and is covered by
// core/tests/authorization-bootstrap.integration.ts.
});
});
+164
View File
@@ -0,0 +1,164 @@
import type { Pool } from "pg";
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import {
AuthorizationDatabaseError,
createAuthorizationDrizzleDatabase,
createAuthorizationDrizzleView,
} from "../src/authorization/database.js";
describe("Core authorization Drizzle database composition", () => {
it("does not construct a pool merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it.each([
"",
" ",
"not-a-database-url",
"https://admin:[email protected]/database",
])("rejects an invalid connection string without exposing it (%s)", (connectionString) => {
let captured: unknown;
try {
createAuthorizationDrizzleDatabase(connectionString);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationDatabaseError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
if (connectionString) {
expect(publicText).not.toContain(connectionString);
}
expect(poolConstructor).not.toHaveBeenCalled();
});
it("explicitly constructs the pool only when called, with the authorization schema attached", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
const handle = createAuthorizationDrizzleDatabase(
"postgresql://operator:[email protected]/ekklesia",
);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(handle.db).toBeDefined();
expect(typeof handle.close).toBe("function");
expect(Object.isFrozen(handle)).toBe(true);
});
it("passes the connection timeout through to the pool configuration", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
connectionTimeoutMs: 5_000,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
);
});
it("passes the operation timeout through to the pool configuration as both query_timeout and statement_timeout", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
operationTimeoutMs: 7_500,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ query_timeout: 7_500, statement_timeout: 7_500 }),
);
});
it("does not set query_timeout, statement_timeout, or connectionTimeoutMillis when no timeout is configured", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
const [poolConfig] = poolConstructor.mock.calls[0] as [Record<string, unknown>];
expect(poolConfig).not.toHaveProperty("query_timeout");
expect(poolConfig).not.toHaveProperty("statement_timeout");
expect(poolConfig).not.toHaveProperty("connectionTimeoutMillis");
});
it.each([
["operationTimeoutMs", 0],
["operationTimeoutMs", -1],
["operationTimeoutMs", Number.NaN],
["operationTimeoutMs", 1.5],
["operationTimeoutMs", 2_147_483_648],
["connectionTimeoutMs", 0],
["connectionTimeoutMs", -1],
["connectionTimeoutMs", Number.NaN],
] as const)("rejects an invalid %s (%s) without constructing a pool and without leaking the value", (optionName, value) => {
poolConstructor.mockClear();
let captured: unknown;
try {
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
[optionName]: value,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationDatabaseError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain(String(value));
expect(poolConstructor).not.toHaveBeenCalled();
});
it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => {
const on = vi.fn();
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on };
});
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
expect(on).toHaveBeenCalledWith("error", expect.any(Function));
const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as
| ((error: unknown) => void)
| undefined;
expect(() =>
handler?.(new Error("terminating connection due to administrator command")),
).not.toThrow();
});
});
describe("createAuthorizationDrizzleView", () => {
it("wraps an externally owned pool without constructing, registering an error listener on, or closing one itself", () => {
poolConstructor.mockClear();
const fakePool = { end: vi.fn(), on: vi.fn(), query: vi.fn() };
// A minimal fake pg.Pool double is sufficient — this test proves
// createAuthorizationDrizzleView never touches the pool beyond wrapping it.
const db = createAuthorizationDrizzleView(fakePool as unknown as Pool);
expect(db).toBeDefined();
expect(poolConstructor).not.toHaveBeenCalled();
expect(fakePool.on).not.toHaveBeenCalled();
expect(fakePool.end).not.toHaveBeenCalled();
});
});
+544
View File
@@ -0,0 +1,544 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
parseAuthorizationScope,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type ActiveGrantQuery,
AuthorizationEvaluationError,
type AuthorizationGrantPort,
type AuthorizationPlatformOwnerPort,
type AuthorizeRequest,
createAuthorizationEvaluator,
} from "../src/authorization/index.js";
interface FakeGrant {
readonly userId: string;
readonly permission: string;
readonly scope?: string;
readonly expiresAt?: Date;
readonly revokedAt?: Date;
}
/** An in-memory double implementing the exact scope/expiry/revocation matching semantics the real
* Postgres store implements in SQL, so the evaluator's own decision logic can be exercised in
* isolation. The live integration suite separately proves the SQL performs the same matching. */
class FakeGrantStore implements AuthorizationGrantPort {
readonly grants: FakeGrant[] = [];
readonly calls: ActiveGrantQuery[] = [];
async hasActiveGrant(query: ActiveGrantQuery): Promise<boolean> {
this.calls.push(query);
return this.grants.some((grant) => {
if (grant.userId !== query.userId || grant.permission !== query.permission) {
return false;
}
if (grant.revokedAt !== undefined) {
return false;
}
if (grant.expiresAt !== undefined && grant.expiresAt <= query.now) {
return false;
}
if (grant.scope === undefined) {
return true; // an unscoped grant satisfies any request for the same permission
}
return grant.scope === query.scope; // a scoped grant never satisfies an unscoped request
});
}
}
class FakeOwnerStore implements AuthorizationPlatformOwnerPort {
private readonly owners = new Set<string>();
readonly calls: string[] = [];
makeOwner(userId: string): void {
this.owners.add(userId);
}
async isPlatformOwner(userId: string): Promise<boolean> {
this.calls.push(userId);
return this.owners.has(userId);
}
}
const userId = parseEkklesiaUserId("user_1");
const otherUserId = parseEkklesiaUserId("user_2");
const permission = parsePermissionId("calendar.events.read");
const scope = parseAuthorizationScope("calendar-event:123");
const otherScope = parseAuthorizationScope("calendar-event:456");
describe("Core authorization evaluator", () => {
let grants: FakeGrantStore;
let owners: FakeOwnerStore;
beforeEach(() => {
grants = new FakeGrantStore();
owners = new FakeOwnerStore();
});
function evaluator(now?: () => Date) {
return createAuthorizationEvaluator({
grants,
platformOwners: owners,
...(now ? { now } : {}),
});
}
describe("public baseline", () => {
it("allows an anonymous principal", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: true });
});
it("allows an authenticated principal", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("never queries grants or platform-owner state for a public check", async () => {
await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an anonymous principal when the permission id is malformed, without throwing or querying grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission: "not a valid permission id",
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal when the permission id is malformed", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("authenticated baseline", () => {
it("allows an authenticated principal", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("rejects an anonymous principal", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
});
it("never queries grants or platform-owner state for an authenticated check", async () => {
await evaluator().authorize({
baseline: "authenticated",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal when the permission id is malformed, without throwing or querying grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("still denies an anonymous principal when the permission id is malformed too", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission: "not a valid permission id",
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("restricted baseline", () => {
it("denies an anonymous principal without ever consulting grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal with no grant and no ownership", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("allows an authenticated principal holding an active explicit grant", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("allows an authenticated principal holding an active relationship-derived grant", async () => {
// The evaluator only distinguishes "active grant or not" through the port; source kind is a
// store/audit concern, not an evaluation input — proven here by a grant the fake store
// exposes with no source-kind field at all still being honored.
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("matches permissions exactly and does not treat a namespace prefix as inherited authority", async () => {
grants.grants.push({ permission: parsePermissionId("calendar.events.read.detail"), userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies a grant that belongs to a different user", async () => {
grants.grants.push({ permission, userId: otherUserId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("lets an unscoped grant satisfy an unscoped request", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("lets an unscoped grant satisfy a scoped request for the same permission", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("lets a scoped grant satisfy a request for the exact matching scope", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("denies a scoped grant against a different scope", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope: otherScope,
});
expect(decision).toEqual({ allowed: false });
});
it("never lets a scoped grant satisfy an unscoped request", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies an expired grant", async () => {
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
grants.grants.push({
expiresAt: new Date("2025-12-31T00:00:00.000Z"),
permission,
userId,
});
const decision = await evaluator(() => fixedNow).authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("allows a grant that has not yet expired, evaluated deterministically against an injected clock", async () => {
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
grants.grants.push({
expiresAt: new Date("2026-01-02T00:00:00.000Z"),
permission,
userId,
});
const decision = await evaluator(() => fixedNow).authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("denies a revoked grant even though it has not expired", async () => {
grants.grants.push({
expiresAt: new Date(Date.now() + 86_400_000),
permission,
revokedAt: new Date(),
userId,
});
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies an unknown or malformed permission without throwing", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
});
it("denies a wildcard-shaped permission without throwing", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: "media.*",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies a non-string permission (number, object, null, undefined) without throwing", async () => {
for (const malformed of [42, {}, null, undefined, ["calendar.events.read"]]) {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: malformed,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
}
});
it("denies a malformed scope without throwing", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope: "",
});
expect(decision).toEqual({ allowed: false });
});
it("allows a platform owner with no matching grant at all", async () => {
owners.makeOwner(userId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("allows a platform owner for a scoped restricted permission with no scoped grant", async () => {
owners.makeOwner(userId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("does not grant ownership authority to a different user", async () => {
owners.makeOwner(otherUserId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
});
describe("an unknown or malformed baseline", () => {
it("denies rather than defaulting to any known baseline", async () => {
for (const malformed of ["superuser", "", 42, null, undefined, "Public", "PUBLIC"]) {
const decision = await evaluator().authorize({
baseline: malformed,
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
}
});
});
describe("a client cannot inject owner or grant state", () => {
it("ignores extra caller-supplied fields such as isOwner/hasGrant and re-derives authority from the injected ports", async () => {
// Simulates a caller that smuggles extra fields the AuthorizeRequest type does not
// declare — going through `unknown` rather than `any` proves the object still satisfies
// AuthorizeRequest structurally; the extra fields are simply never read.
const hostileRequest = {
baseline: "restricted",
hasGrant: true,
isOwner: true,
permission,
principal: createAuthenticatedPrincipal(userId),
} as unknown as AuthorizeRequest;
const decision = await evaluator().authorize(hostileRequest);
expect(decision).toEqual({ allowed: false });
// The evaluator still had to actually ask the injected ports — it did not shortcut on the
// hostile flags.
expect(grants.calls).toHaveLength(1);
expect(owners.calls).toHaveLength(1);
});
});
describe("decision contract", () => {
it("returns a frozen, minimal decision with no extra fields", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(Object.isFrozen(decision)).toBe(true);
expect(Object.keys(decision)).toEqual(["allowed"]);
});
});
describe("sanitized failures", () => {
it("propagates a genuine grant-lookup infrastructure failure rather than silently denying", async () => {
const failingGrants: AuthorizationGrantPort = {
hasActiveGrant: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
};
const failingEvaluator = createAuthorizationEvaluator({
grants: failingGrants,
platformOwners: owners,
});
await expect(
failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
}),
).rejects.toMatchObject({
code: "AUTHORIZATION_EVALUATION_FAILED",
});
});
it("propagates a genuine platform-owner lookup infrastructure failure rather than silently denying", async () => {
const failingOwners: AuthorizationPlatformOwnerPort = {
isPlatformOwner: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
};
const failingEvaluator = createAuthorizationEvaluator({
grants,
platformOwners: failingOwners,
});
await expect(
failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
}),
).rejects.toBeInstanceOf(AuthorizationEvaluationError);
});
it("never leaks a raw infrastructure error message through the thrown error", async () => {
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
const failingGrants: AuthorizationGrantPort = {
hasActiveGrant: vi.fn().mockRejectedValue(new Error(hostileMessage)),
};
const failingEvaluator = createAuthorizationEvaluator({
grants: failingGrants,
platformOwners: owners,
});
let captured: unknown;
try {
await failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
} catch (error) {
captured = error;
}
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain("hunter2");
expect(publicText).not.toContain("db.internal.example");
});
});
});
@@ -0,0 +1,94 @@
import { parsePermissionId } from "@ekklesia/contracts";
import { describe, expect, it } from "vitest";
import {
CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_PERMISSION_REGISTRY,
createPermissionRegistry,
PermissionRegistryError,
} from "../src/authorization/index.js";
describe("createPermissionRegistry", () => {
it("builds an immutable registry from a fixed declaration list", () => {
const id = parsePermissionId("test.example.read");
const registry = createPermissionRegistry([
{ baseline: "public", description: "An example permission.", id },
]);
expect(registry.has(id)).toBe(true);
expect(registry.get(id)).toEqual({
baseline: "public",
description: "An example permission.",
id,
});
expect(Object.isFrozen(registry)).toBe(true);
expect(Object.isFrozen(registry.declarations)).toBe(true);
expect(Object.isFrozen(registry.get(id))).toBe(true);
});
it("rejects a duplicate permission id at composition time, not per request", () => {
const id = parsePermissionId("test.example.read");
expect(() =>
createPermissionRegistry([
{ baseline: "public", description: "First.", id },
{ baseline: "restricted", description: "Second.", id },
]),
).toThrow(PermissionRegistryError);
});
it("carries the offending permission id on the duplicate-declaration error (a composition-time, developer-facing detail, not a runtime response)", () => {
const id = parsePermissionId("test.example.read");
let captured: unknown;
try {
createPermissionRegistry([
{ baseline: "public", description: "First.", id },
{ baseline: "restricted", description: "Second.", id },
]);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PermissionRegistryError);
expect(captured).toMatchObject({
code: "PERMISSION_REGISTRY_DUPLICATE_DECLARATION",
permissionId: id,
});
});
it("treats an unregistered permission the same as a malformed one: get/has both fail closed", () => {
const registered = parsePermissionId("test.example.read");
const unregistered = parsePermissionId("test.example.unregistered");
const registry = createPermissionRegistry([
{ baseline: "public", description: "Registered.", id: registered },
]);
expect(registry.has(unregistered)).toBe(false);
expect(registry.get(unregistered)).toBeUndefined();
});
it("fails closed for a malformed permission id passed to get/has, without throwing", () => {
const registry = createPermissionRegistry([]);
expect(registry.has("not a valid permission")).toBe(false);
expect(registry.get(null)).toBeUndefined();
expect(registry.get(undefined)).toBeUndefined();
expect(registry.get(42)).toBeUndefined();
expect(registry.get("media.*")).toBeUndefined();
});
it("builds an empty registry from an empty list without error", () => {
const registry = createPermissionRegistry([]);
expect(registry.declarations).toEqual([]);
});
});
describe("CORE_PERMISSION_REGISTRY", () => {
it("registers exactly core.authorization.grants.manage as restricted", () => {
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_AUTHORIZATION_GRANTS_MANAGE);
expect(declaration).toBeDefined();
expect(declaration?.baseline).toBe("restricted");
expect(declaration?.id).toBe("core.authorization.grants.manage");
});
it("contains no other permission in this slice", () => {
expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1);
});
});
@@ -0,0 +1,539 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
type AuthorizationScope,
createUserActor,
type GrantId,
parseAuthorizationScope,
parseEkklesiaUserId,
parsePermissionId,
SYSTEM_ACTOR,
} from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
AuthorizationStoreError,
createAuthorizationDrizzleDatabase,
createAuthorizationStore,
} from "../src/authorization/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The authorization integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The authorization integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 14);
databaseCounter += 1;
return `ekk_authz_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure remains static when connection cleanup also fails.
}
throw harnessError("The authorization integration database connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function createDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-authz-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { recursive: true, force: true });
}
}
async function seedUser(client: Client, id: string, email: string): Promise<void> {
await safeQuery(
client,
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
values ($1, $2, $3, false, now(), now())`,
[id, "Integration Test User", email],
);
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
describe.sequential("live Core authorization store", () => {
let database: { name: string; url: string };
let admin: Client;
let store: ReturnType<typeof createAuthorizationStore>;
let close: () => Promise<void>;
const userOneId = parseEkklesiaUserId("authz_user_one");
const userTwoId = parseEkklesiaUserId("authz_user_two");
const permission = parsePermissionId("calendar.events.read");
const scope: AuthorizationScope = parseAuthorizationScope("calendar-event:123");
const actor = createUserActor("authz_user_two"); // an operator/admin user distinct from the subject
it("applies a fresh migration and creates exactly the three authorization tables with their constraints", async () => {
database = await createDisposableDatabase("fresh");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
const tables = await safeQuery<{ table_name: string }>(
admin,
`select table_name from information_schema.tables
where table_schema = 'public' and table_name like 'ekklesia_core_%'
order by table_name`,
);
expect(tables.map((row) => row.table_name)).toEqual([
"ekklesia_core_account",
"ekklesia_core_authorization_audit",
"ekklesia_core_grant",
"ekklesia_core_migrations",
"ekklesia_core_platform_owner",
"ekklesia_core_session",
"ekklesia_core_user",
"ekklesia_core_verification",
]);
await seedUser(admin, userOneId, "[email protected]");
await seedUser(admin, userTwoId, "[email protected]");
const handle = createAuthorizationDrizzleDatabase(database.url);
close = handle.close;
store = createAuthorizationStore(handle.db);
});
it("creates a grant and finds it as active for the matching user, permission, and scope", async () => {
const grant = await store.createGrant({
actor,
permission,
scope,
sourceKind: "explicit",
userId: userOneId,
});
expect(grant.userId).toBe(userOneId);
expect(grant.permission).toBe(permission);
expect(grant.scope).toBe(scope);
expect(grant.sourceKind).toBe("explicit");
expect(grant.revokedAt).toBeUndefined();
const active = await store.hasActiveGrant({
now: new Date(),
permission,
scope,
userId: userOneId,
});
expect(active).toBe(true);
});
it("does not find the grant for a different user, a different permission, or a different scope", async () => {
expect(
await store.hasActiveGrant({ now: new Date(), permission, scope, userId: userTwoId }),
).toBe(false);
expect(
await store.hasActiveGrant({
now: new Date(),
permission: parsePermissionId("calendar.events.write"),
scope,
userId: userOneId,
}),
).toBe(false);
expect(
await store.hasActiveGrant({
now: new Date(),
permission,
scope: parseAuthorizationScope("calendar-event:456"),
userId: userOneId,
}),
).toBe(false);
});
it("never lets the scoped grant satisfy an unscoped request", async () => {
const active = await store.hasActiveGrant({ now: new Date(), permission, userId: userOneId });
expect(active).toBe(false);
});
it("lets an unscoped grant satisfy both unscoped and scoped requests for the same permission", async () => {
const unscopedPermission = parsePermissionId("calendar.events.list");
await store.createGrant({
actor,
permission: unscopedPermission,
sourceKind: "relationship",
sourceRef: "roster:volunteer:42",
userId: userOneId,
});
expect(
await store.hasActiveGrant({
now: new Date(),
permission: unscopedPermission,
userId: userOneId,
}),
).toBe(true);
expect(
await store.hasActiveGrant({
now: new Date(),
permission: unscopedPermission,
scope: parseAuthorizationScope("anything-at-all"),
userId: userOneId,
}),
).toBe(true);
});
it("records exactly one atomic audit entry for the grant it just created", async () => {
const rows = await safeQuery<{
action: string;
actor_id: string | null;
actor_type: string;
permission_id: string | null;
scope: string | null;
subject_user_id: string;
}>(
admin,
`select action, actor_id, actor_type, permission_id, scope, subject_user_id
from "ekklesia_core_authorization_audit"
where subject_user_id = $1 and permission_id = $2`,
[userOneId, permission],
);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
action: "grant.create",
actor_id: userTwoId,
actor_type: "user",
permission_id: permission,
scope,
subject_user_id: userOneId,
});
});
it("denies a grant once it expires, evaluated against a real elapsed instant", async () => {
const expiringPermission = parsePermissionId("calendar.events.export");
const past = new Date(Date.now() - 1_000);
await store.createGrant({
actor,
expiresAt: past,
permission: expiringPermission,
sourceKind: "explicit",
userId: userOneId,
});
const active = await store.hasActiveGrant({
now: new Date(),
permission: expiringPermission,
userId: userOneId,
});
expect(active).toBe(false);
});
it("allows a grant that has not yet expired", async () => {
const notYetExpiredPermission = parsePermissionId("calendar.events.import");
const future = new Date(Date.now() + 86_400_000);
await store.createGrant({
actor,
expiresAt: future,
permission: notYetExpiredPermission,
sourceKind: "explicit",
userId: userOneId,
});
const active = await store.hasActiveGrant({
now: new Date(),
permission: notYetExpiredPermission,
userId: userOneId,
});
expect(active).toBe(true);
});
const revocablePermission = parsePermissionId("calendar.events.delete");
let revocableGrantId: GrantId;
it("revokes an active grant, records the revocation audit entry, and stops it authorizing", async () => {
const grant = await store.createGrant({
actor,
permission: revocablePermission,
sourceKind: "explicit",
userId: userOneId,
});
revocableGrantId = grant.id;
await store.revokeGrant({ actor, grantId: grant.id });
const active = await store.hasActiveGrant({
now: new Date(),
permission: revocablePermission,
userId: userOneId,
});
expect(active).toBe(false);
const audit = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`,
[revocableGrantId],
);
expect(audit.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
});
it("refuses to revoke an already-revoked grant a second time", async () => {
let captured: unknown;
try {
await store.revokeGrant({ actor, grantId: revocableGrantId });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationStoreError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
});
it("rolls back the whole grant-plus-audit transaction when the audit write violates a real constraint", async () => {
const doomedPermission = parsePermissionId("calendar.events.archive");
// The audit table's scope CHECK constraint rejects a non-null empty string; the grant table
// has no such constraint. Bypassing the application-layer AuthorizationScope validation (which
// never produces an empty string) with a raw cast lets a real Postgres constraint violation
// fire on the audit insert specifically, after the grant insert within the same transaction
// would otherwise have succeeded.
let captured: unknown;
try {
await store.createGrant({
actor,
permission: doomedPermission,
scope: "" as AuthorizationScope,
sourceKind: "explicit",
userId: userOneId,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationStoreError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
const orphanedGrants = await safeQuery(
admin,
`select id from "ekklesia_core_grant" where permission_id = $1`,
[doomedPermission],
);
expect(orphanedGrants).toHaveLength(0);
const orphanedAudit = await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit" where permission_id = $1`,
[doomedPermission],
);
expect(orphanedAudit).toHaveLength(0);
});
it("is not a platform owner before ever being granted ownership", async () => {
expect(await store.isPlatformOwner(userOneId)).toBe(false);
});
it("grants, records the audit entry for, and looks up platform-owner authority", async () => {
await store.grantPlatformOwner(userOneId, actor);
expect(await store.isPlatformOwner(userOneId)).toBe(true);
expect(await store.isPlatformOwner(userTwoId)).toBe(false);
const audit = await safeQuery<{ action: string; subject_user_id: string }>(
admin,
`select action, subject_user_id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
);
expect(audit).toEqual([{ action: "platform_owner.grant", subject_user_id: userOneId }]);
});
it("revokes platform-owner authority, records the audit entry, and stops future lookups from succeeding", async () => {
await store.revokePlatformOwner(userOneId, actor);
expect(await store.isPlatformOwner(userOneId)).toBe(false);
const audit = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where action = 'platform_owner.revoke'`,
);
expect(audit).toHaveLength(1);
});
it("refuses to revoke platform-owner authority a user never held (or already lost)", async () => {
let captured: unknown;
try {
await store.revokePlatformOwner(userTwoId, actor);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationStoreError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND" });
});
it("re-grants platform-owner authority after a revocation (upsert), reactivating rather than duplicating the row", async () => {
await store.grantPlatformOwner(userOneId, SYSTEM_ACTOR);
expect(await store.isPlatformOwner(userOneId)).toBe(true);
const rows = await safeQuery(
admin,
`select user_id from "ekklesia_core_platform_owner" where user_id = $1`,
[userOneId],
);
expect(rows).toHaveLength(1);
});
it("shuts down cleanly", async () => {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
});
});
describe.sequential("live Core authorization store — no automatic migration", () => {
it("creates no authorization tables merely by constructing the database, the store, or an evaluator against an unmigrated database", async () => {
const database = await createDisposableDatabase("unmigrated");
try {
const { db, close } = createAuthorizationDrizzleDatabase(database.url);
const store = createAuthorizationStore(db);
expect(store).toBeDefined();
await close();
const admin = await openAdministrativeClient(database.url);
try {
const tables = await safeQuery(
admin,
`select table_name from information_schema.tables where table_schema = 'public'`,
);
expect(tables).toHaveLength(0);
} finally {
await safeEnd(admin);
}
} finally {
await dropDatabase(database.name);
}
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js";
import { AuthorizationStoreError, createAuthorizationStore } from "../src/authorization/store.js";
describe("Core authorization store — import and construction safety", () => {
it("does not construct a pool or open a connection merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it("does not perform any query merely because the store is constructed", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn(), query: vi.fn() };
});
const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
const store = createAuthorizationStore(db);
expect(store).toBeDefined();
expect(typeof store.createGrant).toBe("function");
expect(typeof store.revokeGrant).toBe("function");
expect(typeof store.hasActiveGrant).toBe("function");
expect(typeof store.grantPlatformOwner).toBe("function");
expect(typeof store.revokePlatformOwner).toBe("function");
expect(typeof store.isPlatformOwner).toBe("function");
expect(Object.isFrozen(store)).toBe(true);
});
});
describe("AuthorizationStoreError", () => {
it.each([
"AUTHORIZATION_GRANT_CREATE_FAILED",
"AUTHORIZATION_GRANT_LOOKUP_FAILED",
"AUTHORIZATION_GRANT_NOT_FOUND",
"AUTHORIZATION_GRANT_REVOKE_FAILED",
"AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED",
"AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED",
"AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND",
] as const)("exposes a stable code and a fixed, generic message for %s", (code) => {
const error = new AuthorizationStoreError(code);
expect(error.code).toBe(code);
expect(error.name).toBe("AuthorizationStoreError");
expect(Object.isFrozen(error)).toBe(true);
expect(error.message.length).toBeGreaterThan(0);
});
it("never carries a cause, raw driver error, SQL, or stack detail beyond the fixed message", () => {
const error = new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED");
const publicText = `${String(error)}\n${error.stack ?? ""}`;
expect(publicText).toBe(
`AuthorizationStoreError: ${error.message}\nAuthorizationStoreError: ${error.message}`,
);
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
describe("bootstrap-platform-owner CLI import safety", () => {
const originalEnv = { ...process.env };
it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => {
delete process.env.DATABASE_URL;
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
// no argv manipulation is needed to prove the guard holds.
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
expect(poolConstructor).not.toHaveBeenCalled();
process.env = originalEnv;
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import { createValidatedPostgresPool, PostgresPoolError } from "../src/database/pool.js";
describe("createValidatedPostgresPool", () => {
it("does not construct a pool merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it.each([
"",
" ",
"not-a-database-url",
"https://admin:[email protected]/database",
])("rejects an invalid connection string without exposing it (%s)", (connectionString) => {
let captured: unknown;
try {
createValidatedPostgresPool(connectionString);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PostgresPoolError);
expect(captured).toMatchObject({ code: "POSTGRES_POOL_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
if (connectionString) {
expect(publicText).not.toContain(connectionString);
}
expect(poolConstructor).not.toHaveBeenCalled();
});
it("explicitly constructs the pool only when called", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
const handle = createValidatedPostgresPool(
"postgresql://operator:[email protected]/ekklesia",
);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(handle.pool).toBeDefined();
expect(typeof handle.close).toBe("function");
expect(Object.isFrozen(handle)).toBe(true);
});
it("passes the connection timeout through to the pool configuration", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createValidatedPostgresPool("postgresql://example.test/ekklesia", {
connectionTimeoutMs: 5_000,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
);
});
it("passes the operation timeout through as both query_timeout and statement_timeout", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createValidatedPostgresPool("postgresql://example.test/ekklesia", {
operationTimeoutMs: 7_500,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ query_timeout: 7_500, statement_timeout: 7_500 }),
);
});
it.each([
["operationTimeoutMs", 0],
["operationTimeoutMs", -1],
["operationTimeoutMs", Number.NaN],
["connectionTimeoutMs", 0],
["connectionTimeoutMs", -1],
] as const)("rejects an invalid %s (%s) without constructing a pool and without leaking the value", (optionName, value) => {
poolConstructor.mockClear();
let captured: unknown;
try {
createValidatedPostgresPool("postgresql://example.test/ekklesia", {
[optionName]: value,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PostgresPoolError);
expect(captured).toMatchObject({ code: "POSTGRES_POOL_CONFIGURATION_INVALID" });
expect(poolConstructor).not.toHaveBeenCalled();
});
it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => {
const on = vi.fn();
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on };
});
createValidatedPostgresPool("postgresql://example.test/ekklesia");
expect(on).toHaveBeenCalledWith("error", expect.any(Function));
const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as
| ((error: unknown) => void)
| undefined;
expect(() =>
handler?.(new Error("terminating connection due to administrator command")),
).not.toThrow();
});
});
+22 -1
View File
@@ -1,10 +1,15 @@
import type { Pool } from "pg";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn()); const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor })); vi.mock("pg", () => ({ Pool: poolConstructor }));
import { createIdentityDrizzleDatabase, IdentityDatabaseError } from "../src/identity/database.js"; import {
createIdentityDrizzleDatabase,
createIdentityDrizzleView,
IdentityDatabaseError,
} from "../src/identity/database.js";
describe("Core identity Drizzle database composition", () => { describe("Core identity Drizzle database composition", () => {
it("does not construct a pool merely because the module is imported", () => { it("does not construct a pool merely because the module is imported", () => {
@@ -141,3 +146,19 @@ describe("Core identity Drizzle database composition", () => {
).not.toThrow(); ).not.toThrow();
}); });
}); });
describe("createIdentityDrizzleView", () => {
it("wraps an externally owned pool without constructing, registering an error listener on, or closing one itself", () => {
poolConstructor.mockClear();
const fakePool = { end: vi.fn(), on: vi.fn(), query: vi.fn() };
// A minimal fake pg.Pool double is sufficient — this test proves createIdentityDrizzleView
// never touches the pool beyond wrapping it.
const db = createIdentityDrizzleView(fakePool as unknown as Pool);
expect(db).toBeDefined();
expect(poolConstructor).not.toHaveBeenCalled();
expect(fakePool.on).not.toHaveBeenCalled();
expect(fakePool.end).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,230 @@
# 0013: Core authorization, permission registry, and platform-owner bootstrap
- Status: Accepted
- Date: 2026-07-23
## Context
ADR 0010 and ADR 0011 established Core-owned identity and its HTTP transport but explicitly left
authorization out of scope: every account received identical baseline authenticated status, and
signup order never granted elevation. `docs/architecture/platform-architecture.md` names
permissions as a Core concern (ADR 0001), not a feature module or a surface concern, alongside
identity, database connectivity, and module lifecycle.
Phase 4C adds that missing piece in two slices on this branch. The first slice
(`feat: add core authorization foundation`) established the schema, evaluator, store, and
contracts. The second (`feat: add authorization bootstrap and HTTP enforcement`) added an explicit
platform-owner bootstrap operation, a permission registry, and HTTP enforcement wired into
`apps/api`. This ADR records the durable architectural decisions both slices established, since
together they fix a shape future changes must not casually violate: what a permission is, who can
grant one, how a platform owner is established, and how any of this reaches HTTP.
## Decision
### Core owns authorization independently of Hono, Surfaces, and modules
Authorization is a Core platform concern, exactly like identity. The schema
(`core/src/authorization/schema.ts`), evaluator (`core/src/authorization/evaluator.ts`), store
(`core/src/authorization/store.ts`), permission registry (`core/src/authorization/permissions.ts`),
and bootstrap operation (`core/src/authorization/bootstrap.ts`) import nothing from `hono`,
`apps/api`, or any module, and are independently testable without an HTTP transport. `apps/api`
consumes Core's authorization surface the same way it already consumes identity: through explicit,
injected composition (`apps/api/src/authorization-middleware.ts`,
`apps/api/src/grants-routes.ts`), never the reverse. Modules cannot import Core at all under
`scripts/check-boundaries.ts`, so this holds structurally, not just by convention.
### Permission IDs are exact, registered, and deny by default
A `PermissionId` (`@ekklesia/contracts`) is a validated, namespaced string
(`core.authorization.grants.manage`), but being well-formed is not the same as being *registered*.
`PermissionRegistry` (`core/src/authorization/permissions.ts`) is the trusted, immutable,
composition-time-built source of which permissions actually exist and what baseline each requires.
Matching is always exact string equality — there is no wildcard, prefix, or namespace-inheritance
matching anywhere in the evaluator or the store; a permission `core.authorization.grants.manage`
never implies or is implied by `core.authorization.grants.*` or `core.authorization.*`. An unknown
or malformed permission id is treated identically to a denied decision, never a distinct "this
would work if declared" response — deny-by-default holds even for permissions nobody has declared
yet.
Registration is enforced everywhere a permission id can originate from outside Core's own trusted
route wiring, not only for the caller's own required permission. `requirePermission`
(`apps/api/src/authorization-middleware.ts`) checks the registry for the permission a *route*
requires; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately checks the registry
for the *target* permission a caller is requesting be granted to someone else, since that value
comes from the request body rather than route wiring. Without the second check, a caller with
`core.authorization.grants.manage` could create a stored grant for a permission id that is merely
well-formed, not registered — a dormant grant that would silently become effective the moment
that identifier was registered later, with no further action or review at registration time. Both
checks reject with the identical sanitized `403 {"code":"AUTHORIZATION_DENIED"}` an ordinary denial
would return, and neither ever calls into the store on rejection.
### Permissions declare one trusted baseline: public, authenticated, or restricted
Every registered permission declares exactly one `PermissionBaseline` at registration time. The
evaluator (`core/src/authorization/evaluator.ts`) still accepts a baseline as part of its own
request shape — that is pass 1's evaluator contract, unchanged — but the only caller allowed to
supply it in this repository is Core's own trusted registry lookup
(`requirePermission` in `apps/api/src/authorization-middleware.ts`); no HTTP route reads a baseline
from a request header, query parameter, or body. `public` always allows; `authenticated` requires a
non-anonymous principal and nothing else; `restricted` requires an authenticated principal *and*
either platform-owner authority or an active matching grant — the baseline alone never satisfies a
restricted check.
### Unknown permissions and client-supplied baselines fail closed
Two independent fail-closed layers exist on purpose, not one relied on twice, and they check
different things. The registry itself (`PermissionRegistry.get`/`.has`) returns `undefined`/`false`
for anything malformed or unregistered — this is the only layer that knows what is *registered*.
`requirePermission` treats a registry miss as an immediate sanitized `403`, before principal
resolution or evaluation ever runs; `POST /api/v1/grants` performs the equivalent registry check
for the target permission named in its body (see above). Separately, `createAuthorizationEvaluator`
independently re-validates permission *shape* — it holds no registry and cannot recognize
registration at all — denying (without throwing) any malformed or unrecognized
`permission`/`baseline`/`scope` it receives. This shape check runs, and can deny, **before any
baseline is allowed to return an allowed decision** — `public` and `authenticated` are not
exempt, even though neither reads the permission value for any other reason — so a future caller
that reaches the evaluator directly, bypassing the registry entirely (for example a different
transport Core does not yet have), inherits the same fail-closed guarantee for malformed input
rather than depending on a registry check having already run upstream. The evaluator's shape check
and the registry's registration check are deliberately not merged into one: only the registry can
answer "is this permission registered," and the evaluator must remain usable — and safe against
malformed input — by a caller that has no registry to consult at all.
### Explicit and relationship-derived grants share persistence but have distinct source kinds
`ekklesia_core_grant` is one table for both explicit grants (an administrator naming a subject
user, permission, and optional scope directly) and relationship-derived grants (a future module
operation establishing access automatically from a relationship it owns, for example a roster
assignment). A `source_kind` column, constrained to exactly `'explicit'` or `'relationship'`,
distinguishes them at the row level; nothing else about how a grant is stored or evaluated differs
by source kind. This lets both kinds share one evaluator code path and one audit trail while still
letting HTTP enforcement treat them very differently (see below).
### Unscoped grants satisfy scoped or unscoped checks; scoped grants satisfy only the exact matching scope
`AuthorizationStore.hasActiveGrant` (`core/src/authorization/store.ts`) matches a grant whose
`scope` column is `NULL` against *any* check, scoped or unscoped, but matches a grant with a
non-null `scope` only against a check requesting that exact scope string. An unscoped grant is
strictly broader than any scoped grant of the same permission; a scoped grant never leaks into an
unscoped check for the same permission. There is no partial, prefix, or hierarchical scope
matching.
### Revoked and expired grants never authorize
`hasActiveGrant`'s query requires `revoked_at IS NULL` and either no `expires_at` or
`expires_at` in the future, evaluated against an injectable clock (`now`) rather than each caller's
own wall-clock read. Revocation is soft: `AuthorizationStore.revokeGrant` sets `revoked_at` and the
revoking actor rather than deleting the row, so the audit trail and the grant's own history remain
queryable after revocation, but a revoked row can never again satisfy an authorization check.
### Platform-owner authority is represented separately from grants and is not a wildcard permission
`ekklesia_core_platform_owner` is its own table, not a synthetic all-permissions grant row.
`createAuthorizationEvaluator` checks `platformOwners.isPlatformOwner(userId)` as an independent
`OR` alongside `hasActiveGrant`, so owner status bypasses the grant/scope machinery entirely rather
than being implemented as a grant with a special scope or permission value that other code could
accidentally match against. This keeps "is a platform owner" and "has this specific permission"
structurally distinct, so a future owner-management feature can reason about the one table without
touching grant semantics.
### Platform-owner bootstrap is explicit, CLI-only, globally single-use, permanently consumed even after revocation, concurrency-safe, and audited atomically
There is no signup-order or first-user inference anywhere in this repository. The only way to
establish a platform owner is `pnpm authorization:bootstrap-owner -- --user-id <id>
--confirm-user-id <same-id>` (`core/src/cli/bootstrap-platform-owner.ts`,
`core/src/authorization/bootstrap.ts`), never an HTTP endpoint. "Bootstrap has already been
consumed" is defined as *any row ever created* in `ekklesia_core_platform_owner`, for any user,
including one whose `revoked_at` is set — there is no path in this slice that re-opens bootstrap
after a revocation; a later, separate owner-management feature (not part of this slice) is how a
platform owner would ever be re-established. The single-use check, the target user's existence
check, the owner-row insert, and its audit-row insert are one PostgreSQL transaction, serialized by
a transaction-scoped advisory lock (`pg_advisory_xact_lock`, a key pair distinct from the migration
runner's own lock) acquired as the transaction's first statement, so two concurrent bootstrap
attempts can never both observe "no owner yet" — exactly one succeeds, proven against real
PostgreSQL. Any failure path (nonexistent user, lost race, audit failure) rolls back the entire
transaction, leaving no partial owner or bootstrap state.
### Identity and Authorization use typed Drizzle views over one physical PostgreSQL pool in apps/api
`apps/api/src/database.ts`'s `createApiDatabase` constructs exactly one validated `pg.Pool`
(`core/src/database/pool.ts`) and wraps it with two independent typed Drizzle views
(`createIdentityDrizzleView`, `createAuthorizationDrizzleView`) rather than owning two pools. This
is additive: `createIdentityDrizzleDatabase` and `createAuthorizationDrizzleDatabase` (each owning
a private pool) remain available and unaffected for callers — the bootstrap CLI included — that
only need one domain and have no reason to share a connection. `apps/api` closes the one shared
pool exactly once, on graceful shutdown and on any startup failure after the pool was already
constructed.
### Cookie-authenticated authorization mutations require an exact trusted Origin
`requireTrustedOrigin` (`apps/api/src/authorization-middleware.ts`) requires an exact match against
the same trusted-origin set already used for CORS and Better Auth's own checks (ADR 0011, ADR
0012) on every grant-mutation route. This is deliberately a server-side check distinct from CORS:
CORS response headers only stop a browser from *reading* a cross-origin response, not from
*sending* the state-changing request in the first place. A missing or untrusted origin is rejected
before the permission check or any state change. Non-browser, non-cookie automation (service
accounts/API tokens) has no way to satisfy this check and is explicitly deferred, not silently
exempted.
### Generic HTTP grant management handles only explicit grants
`POST /api/v1/grants` always sets `source_kind` to `'explicit'` server-side — the request body has
no field that can influence it — and `POST /api/v1/grants/:grantId/revoke` always filters its
mutation to `source_kind = 'explicit'`. A relationship-derived grant's id, presented to the revoke
route, matches zero rows and returns the same `404` a nonexistent grant id would, indistinguishably
from the caller's perspective. The generic HTTP API also never accepts `actor`, `sourceKind`,
`sourceRef`, any audit field, a grant id, `createdAt`, or `revokedAt` in a mutation body: the
allowlist rejects the entire request if any other key is present, so a well-formed-looking request
cannot smuggle a server-derived field past validation.
### Relationship-derived grants may be created or revoked only by the trusted module operation owning that relationship
No code path in this repository creates a `source_kind = 'relationship'` grant yet — there is no
roster or similar module operation in this slice — but the architectural boundary is fixed here:
when that operation exists, it is the only trusted caller for that grant's lifecycle, exercised
through `AuthorizationStore` directly (as pass 2's own integration tests do to seed one), never
through the generic `/api/v1/grants` HTTP surface. Nothing about this ADR assumes what that future
module operation looks like — only that it does not go through the explicit-grant API.
### Deferred
Roles, groups, teams, and tenants; an owner-management HTTP endpoint (revoking or re-granting
platform-owner status over HTTP); service accounts and API tokens for non-browser automation; and
module permission discovery/registration (a mechanism for a module to contribute its own
declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and
remain later work.
## Alternatives
- Representing platform-owner status as a special grant row (a synthetic `*` permission, or a
reserved scope value meaning "everything") was rejected: it would make "is this row an owner
grant or a real permission grant" a runtime distinction every future grant-handling code path
would need to remember to check, rather than a structural one enforced by table shape.
- Implementing bootstrap as "check for an existing owner, then call the same `grantPlatformOwner`
function grants already use" was rejected: composing two separately-transacted operations leaves
a window between the check and the grant where a concurrent caller could observe the same "no
owner yet" state, which is exactly the race a single-use bootstrap must not allow. A dedicated,
single-transaction operation closes that window structurally instead of relying on discipline at
every call site.
- Allowing the generic explicit-grant HTTP API to also manage relationship-derived grants (with an
optional `sourceKind` field in the request body) was rejected: it would let any caller holding
`core.authorization.grants.manage` silently unwind access a module relationship established for
reasons the generic API has no way to evaluate — for example, revoking a roster-derived grant
without the roster module ever knowing its own invariant was violated.
- A second physical PostgreSQL pool dedicated to authorization in `apps/api` (mirroring identity's
own private-pool factory) was rejected in favor of one shared pool: `apps/api` is one process
with one connection budget, and two independently-sized pools would double configured connection
limits for no isolation benefit within a single process.
## Consequences
Every HTTP route that needs authorization now goes through one reusable middleware
(`requirePermission`) backed by one trusted registry, rather than each route independently deciding
how to check a permission. Establishing a platform owner is a deliberate, auditable, one-time
operator action with no ambiguity about who holds it or how it was granted. Future work extending
authorization — module-declared permissions, relationship-derived grants, roles — has a fixed shape
to extend rather than a green field: new permissions register through the same
`createPermissionRegistry` constructor, new grant sources use the existing `source_kind` column
and audit table, and any new HTTP surface reuses `requirePermission`/`requireTrustedOrigin` rather
than reimplementing enforcement. No identity behavior, route, or response shape from ADR 0010,
0011, or 0012 changed.
+1
View File
@@ -21,3 +21,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted - [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted
- [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted - [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted
- [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted - [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
+30 -19
View File
@@ -9,6 +9,8 @@ A follow-up corrective slice hardened this transport (sanitized Better Auth logg
production-equivalent origin/CSRF protection under the test runner, bounded identity database production-equivalent origin/CSRF protection under the test runner, bounded identity database
timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md). above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner
bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
## What this is, and is not ## What this is, and is not
@@ -54,6 +56,7 @@ No real values are shown here — configure per environment and never commit the
| --- | --- | --- | | --- | --- | --- |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). | | `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
deterministic `404 {"code":"NOT_FOUND"}`. deterministic `404 {"code":"NOT_FOUND"}`.
@@ -101,11 +104,12 @@ exist or treats an empty database as special. Baseline authenticated status is w
let a signed-in person use ownership-scoped personal features (watch history, continue watching, let a signed-in person use ownership-scoped personal features (watch history, continue watching,
saved content, preferences) — none of those features are implemented in this PR. saved content, preferences) — none of those features are implemented in this PR.
Explicit **platform-owner bootstrap is deferred to Phase 4C**, which must introduce an explicit, Explicit **platform-owner bootstrap** is now available as a local operator command — see
auditable operator command that grants an *existing* identity platform-owner authority — signup [Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity
order is never part of that decision. Future **relationship-derived access** (for example, a platform-owner authority; signup order is never part of that decision. Future
person added to a volunteer roster automatically gaining narrow, reversible read access to that **relationship-derived access** (for example, a person added to a volunteer roster automatically
roster) belongs to a later roster slice, not this one. gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this
one.
`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not** `emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not**
automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field
@@ -204,15 +208,19 @@ abuse-control design.
## Database connection limits ## Database connection limits
`apps/api/src/server.ts` configures the identity PostgreSQL pool (`createIdentityDrizzleDatabase`, `apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
`core/src/identity/database.ts`) with both a connection timeout and an operation timeout (10 `apps/api/src/database.ts`, built on `createValidatedPostgresPool` from
seconds each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` `core/src/database/pool.ts`) with both a connection timeout and an operation timeout (10 seconds
already uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` already
nothing once a query is already running on one. The operation timeout additionally sets `pg`'s uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does nothing once
`query_timeout` and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than a query is already running on one. The operation timeout additionally sets `pg`'s `query_timeout`
holding a pool connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
negative, or larger than PostgreSQL's own maximum) is rejected as configuration error before any connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
pool is constructed, sanitized the same way an invalid connection string already is. larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
constructed, sanitized the same way an invalid connection string already is. Both the identity and
authorization domains read from this one pool through independent typed Drizzle views — see
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
not two.
## Graceful shutdown ## Graceful shutdown
@@ -221,7 +229,9 @@ executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()
shared promise the first time it is called: every caller, concurrent or sequential, awaits the shared promise the first time it is called: every caller, concurrent or sequential, awaits the
exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and
lets in-flight requests finish, then closes the PostgreSQL pool. The executable entry point's lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and
authorization both read from it — see
[Authorization](authorization.md#shared-database-composition)). The executable entry point's
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the `SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the
same function production composition uses, but independently testable with a fake `running`/`exit` same function production composition uses, but independently testable with a fake `running`/`exit`
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real so a test can trigger "a signal arrived" without sending a real process signal or invoking the real
@@ -268,7 +278,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
## Next phase ## Next phase
Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants / Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
that does not exist yet. pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
Dashboard Surface, which still depends on an authorization model that is only now getting started.
+336
View File
@@ -0,0 +1,336 @@
# Authorization
Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into
`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the
platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it.
See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the
HTTP transport it is mounted into, and [ADR
0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable
architectural decisions this page describes in more implementation detail.
## What this is, and is not
After this slice, an operator can bootstrap exactly one platform owner for a deployment, a
platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization
grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of
silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants;
there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not
exposed at all in this slice); there are no service accounts or API tokens, so only
cookie-authenticated browser callers can reach the mutation routes; and relationship-derived
grants (for example, automatic access from a roster assignment) cannot yet be created by anything
in this repository — the generic grants API structurally cannot create or revoke them either.
## Core authorization schema and store
Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner`,
`ekklesia_core_grant`, `ekklesia_core_authorization_audit`; `AuthorizationEvaluator`,
`AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope`
branded types). This page does not restate that design — read `core/src/authorization/schema.ts`,
`evaluator.ts`, and `store.ts` directly for it. This page covers what pass 2 added on top: the
permission registry, the bootstrap operation, and HTTP enforcement.
## Permission registry
`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable,
composition-time-built `PermissionRegistry` distinguishing a *registered declaration*
(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties
matter:
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
is declared twice — this can only happen while wiring the registry itself, not at request time.
- **Unknown permissions fail closed at lookup time.** `registry.get(permissionId)` and
`registry.has(permissionId)` both re-validate their input (`safeParsePermissionId` internally)
and return `undefined`/`false` for anything malformed or simply not registered — there is no
"trust this permission id because it looks right" path.
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission:
| Permission | Baseline | Description |
| --- | --- | --- |
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
Module-owned permission declarations and a discovery/composition path for them are out of scope
for this slice (deferred to the Module SDK work).
### Two layers, not one: shape versus registration
`createAuthorizationEvaluator` (`core/src/authorization/evaluator.ts`) has no registry and is
never given one — it independently re-validates `permission`, `baseline`, and `scope` as a pure
shape check (`safeParsePermissionId`/`permissionBaselineSchema`/`safeParseAuthorizationScope`) and
denies a malformed value under **every** baseline, including `public` and `authenticated`, before
any baseline is allowed to return an allowed decision. This is defense in depth for a future
caller that reaches the evaluator directly, not a substitute for the registry: the evaluator has
no way to tell a well-formed-but-unregistered permission id (for example a plausible-looking
`calendar.events.read` that nothing has ever declared) from a genuinely registered one — it can
only reject what is not even shaped like a `PermissionId` at all.
*Registration* — "does this permission actually exist" — is exclusively the registry's
responsibility, consulted by every trusted caller before the evaluator ever runs:
`requirePermission` (`apps/api/src/authorization-middleware.ts`) looks up the *caller's own*
required permission (for example `core.authorization.grants.manage`, always a hardcoded route
constant, never client-supplied) in the registry before resolving a principal or evaluating
anything; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately looks up the *target*
permission being granted — the one named in the request body — in the same registry, after body
shape-validation and before ever calling `store.createGrant`, so a caller cannot create a dormant
grant for an invented permission id that might later become effective the moment someone actually
registers it. A malformed target permission (fails `PermissionId` shape validation) still resolves
to the body-level `400 {"code":"INVALID_REQUEST"}` documented below, unchanged; a well-formed but
unregistered target permission instead receives the identical sanitized
`403 {"code":"AUTHORIZATION_DENIED"}` a genuinely denied caller would, so that response never
reveals whether the caller was denied or the permission was simply never registered.
## Platform-owner bootstrap
There is no "first user becomes owner" inference anywhere in this repository, and no HTTP
endpoint exposes bootstrap. The only way to establish a platform owner is the local CLI command:
```bash
pnpm migrations:apply # the database must already be migrated
DATABASE_URL=postgresql://... pnpm authorization:bootstrap-owner -- \
--user-id <existing-ekklesia-user-id> \
--confirm-user-id <same-id>
```
`DATABASE_URL` is read only when the command actually executes (never merely by importing the CLI
module — proved by `core/tests/cli-bootstrap-platform-owner.test.ts`), and it is never printed,
logged, or included in any error. `--user-id` and `--confirm-user-id` must match exactly, checked
before the id is even parsed as a valid `EkklesiaUserId` — this command never infers a target from
signup order or "the only user in the database"; the operator must name the exact, opaque id
twice.
### Bootstrap is permanently single-use
`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated,
Hono-independent Core operation — deliberately *not* a composition of "check for an owner, then
call the existing grant function." "Already consumed" means **any row has ever existed** in
`ekklesia_core_platform_owner`, for any user, including one whose `revoked_at` is set. There is no
owner-management flow in this slice that clears that state; a revoked owner staying revoked (and
bootstrap staying consumed) is intentional here.
The single-use check, the target user's existence check, the owner-grant insert, and its audit
entry are all one PostgreSQL transaction. The very first statement inside that transaction is a
transaction-scoped advisory lock:
```sql
select pg_advisory_xact_lock(1500000001, 1500000002)
```
This lock key pair is distinct from the migration runner's own advisory lock
(`core/src/migrations/postgres-database.ts`), so bootstrap serialization can never collide with or
be blocked by unrelated migration locking. `pg_advisory_xact_lock` releases automatically on
commit or rollback, so no manual unlock path is needed even when the transaction fails partway.
Two concurrent bootstrap attempts serialize on this lock: the second caller blocks until the
first's transaction commits or rolls back, then re-evaluates the "does any owner row exist" check
against now-current state — so exactly one of two concurrent attempts ever succeeds, proved live
against real PostgreSQL in `core/tests/authorization-bootstrap.integration.ts`
(`Promise.allSettled` on two simultaneous calls; asserts exactly one `fulfilled`, exactly one
`rejected` with `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED`, exactly one owner row, exactly one
audit row).
A failed target-user lookup, an audit-insert failure, or losing the concurrency race all roll back
the entire transaction — there is no partial state where an owner row exists without its audit
entry, or vice versa.
The audit entry for a bootstrap grant always uses a fixed, sanitized, non-user actor identity —
never derived from any caller input — so an auditor can tell a given `platform_owner.grant` row
came specifically from this command rather than from a future owner-management flow:
| Field | Value |
| --- | --- |
| `actor_type` | `system` |
| `actor_id` | `authorization-bootstrap-cli` |
### Error codes
`PlatformOwnerBootstrapError` never carries the underlying database error, SQL text, host, port,
path, or stack — only a stable `code` and a fixed message:
| Code | Meaning |
| --- | --- |
| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | A platform-owner row already exists (any user, revoked or not). |
| `PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. |
| `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). |
## HTTP authorization enforcement
`apps/api/src/authorization-middleware.ts` exports `requirePermission(options, permissionId)`, a
Hono middleware factory reused by every protected route:
1. Looks up `permissionId` in the trusted, server-side `PermissionRegistry` (never a
client-supplied baseline) — an unregistered permission id fails closed with `403` before the
principal is even resolved. In today's wiring only hard-coded registered constants are ever
passed by route code, but the check stays live per-request as defense in depth rather than
being trusted once at composition time.
2. Resolves the principal exclusively through the same server-side identity runtime
`/api/v1/principal` already uses (`EkklesiaIdentityRuntime.resolvePrincipal`) — nothing here
ever trusts a client-supplied claim about identity.
3. Calls the injected `AuthorizationEvaluator` with the registry's baseline and permission for
that principal (platform owners pass through the evaluator's owner bypass here — this is how
delegation to `core.authorization.grants.manage` recipients also works, without a second code
path).
4. On success, stores the resolved `EkklesiaPrincipal` (Core's narrow `{kind, userId}` — never a
Better Auth session/user object) on the Hono context via `c.set`, readable by the route through
`getAuthorizedPrincipal(c)`. This is a deliberately narrow exception to the "no generalized
session/user object in Hono context" concern [ADR
0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) raised —
only this already-public-safe principal shape is ever set.
A denied decision and an unregistered permission id both return a sanitized `403
{"code":"AUTHORIZATION_DENIED"}`. A genuine infrastructure failure — principal resolution,
evaluation, or the database underneath either — is never folded into that `403`; it returns a
sanitized `503` instead (`PRINCIPAL_RESOLUTION_FAILED` or `AUTHORIZATION_EVALUATION_FAILED`),
mirroring `/api/v1/principal`'s existing rule that "could not be determined" must never look like
"no permission." Neither response ever reveals whether a grant or owner row exists for the caller.
### Origin enforcement for mutations
`requireTrustedOrigin(trustedOrigins)` requires an exact match against the same
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` set the API already uses for CORS and Better Auth's own
checks (see [API](api.md#origin-cors-and-cookies)) — a missing or untrusted `Origin` header is
rejected with `403 {"code":"UNTRUSTED_ORIGIN"}` before the permission check or the route handler
ever runs. This exists because CORS response headers alone are not CSRF protection: they only
stop a browser from *reading* a cross-origin response, not from *sending* the state-changing
request in the first place. Every grants mutation route (`POST /api/v1/grants`,
`POST /api/v1/grants/:grantId/revoke`) requires this; the read-only list route does not.
Non-browser, non-cookie automation (service accounts/API tokens) is out of scope for this slice
and has no way to satisfy this check yet.
## Explicit-grant HTTP API
Mounted at the API's root by `apps/api/src/grants-routes.ts`. Every route requires
`core.authorization.grants.manage`. All three routes only ever act on *explicit* grants — the
generic HTTP API structurally cannot create or revoke a relationship-derived grant.
| Route | Origin required | Notes |
| --- | --- | --- |
| `GET /api/v1/grants?userId=<id>` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). |
| `POST /api/v1/grants` | Yes | Creates a new explicit grant. |
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. |
### `POST /api/v1/grants` request body
Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`,
`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or
anything else) makes the **entire body** invalid rather than being silently dropped, so a caller
cannot smuggle a server-derived field through a request that otherwise looks well-formed:
| Field | Required | Notes |
| --- | --- | --- |
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. |
| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. |
| `expiresAt` | No | An ISO date string if present. |
The server always derives, and the request body can never supply: the grant id, `sourceKind`
(always `"explicit"`), the actor (always the authenticated caller, via
`createUserActor(principal.userId)`), and all audit/timestamp fields.
### Response shapes
A grant, wherever returned:
```json
{
"id": "...",
"userId": "...",
"permission": "core.authorization.grants.manage",
"scope": null,
"sourceKind": "explicit",
"createdAt": "2026-07-23T00:00:00.000Z",
"expiresAt": null,
"revokedAt": null
}
```
`GET /api/v1/grants` → `200 {"grants": [...]}`. `POST /api/v1/grants` → `201 {"grant": {...}}`.
`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`.
### Status-code mapping
| Status | When |
| --- | --- |
| `200` / `201` | Success. |
| `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). |
| `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`; the caller's own required permission id was unregistered; or, for `POST /api/v1/grants`, the well-formed *target* permission named in the body is not registered. |
| `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. |
| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. |
| `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). |
Every response sets `Cache-Control: no-store`.
## Shared database composition
`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api`
touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown,
and on any startup failure after the pool was already constructed (mirroring the existing
identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
The existing per-domain factories (`createIdentityDrizzleDatabase`,
`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are
unaffected and remain available for standalone/testable use — the CLI bootstrap command uses
`createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs
the authorization schema and has no reason to share a pool with anything else.
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization
usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers
there are or whether the failure happened during startup.
## Tests
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY` contents.
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
per-domain database tests).
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`
behavior.
- `core/tests/cli-bootstrap-platform-owner.test.ts` — the CLI never constructs a pool merely by
being imported.
- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
still blocking reuse, and the concurrency scenario described above.
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior.
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
and malformed-field rejection, a well-formed but unregistered target permission rejected with
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
store-error mapping.
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above.
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
consulting the grant or platform-owner ports for `public`/`authenticated`.
- `apps/api/tests/api-http.integration.ts` (live PostgreSQL) — end-to-end: anonymous/non-owner
denial, real bootstrap against the running API's own database, owner authority over the HTTP
grants API, the owner delegating `core.authorization.grants.manage` to another account and that
account then managing grants as a non-owner, a well-formed but unregistered target permission
rejected with `403` (with no grant or audit row written) even for a caller with full
grant-management authority, a create/list/revoke cycle with audit-trail verification (using
`core.authorization.grants.manage` itself as the granted permission, since module permission
registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection,
a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an
infrastructure-failure scenario (database dropped out from under a live session) asserting a
`503` rather than a false `403` or `200`.
Run the focused suites:
```bash
pnpm --filter @ekklesia/core test
pnpm --filter @ekklesia/api test
pnpm test:migrations:integration # requires Docker
```
## Next phase
Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission
registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service
accounts/API tokens for non-browser automation, and relationship-derived grant creation itself
(a roster or similar module operation, not the generic grants API).
+2 -1
View File
@@ -106,8 +106,9 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
1 migration · checksums valid 2 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0
PostgreSQL PostgreSQL
○ Connection check skipped ○ Connection check skipped
+1
View File
@@ -11,6 +11,7 @@
"packageManager": "[email protected]", "packageManager": "[email protected]",
"scripts": { "scripts": {
"api:start": "turbo run build && node --env-file-if-exists=.env apps/api/dist/server.js", "api:start": "turbo run build && node --env-file-if-exists=.env apps/api/dist/server.js",
"authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js",
"boundaries": "node scripts/check-boundaries.ts", "boundaries": "node scripts/check-boundaries.ts",
"build": "turbo run build", "build": "turbo run build",
"ci:verify": "pnpm verify && pnpm infrastructure:config", "ci:verify": "pnpm verify && pnpm infrastructure:config",
+119
View File
@@ -0,0 +1,119 @@
import { z } from "zod";
import type { EkklesiaUserId } from "./identity.js";
import { parseEkklesiaUserId } from "./identity.js";
/**
* Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId` and
* `PermissionId`: a plain `string` is not assignable to `GrantId` without passing through
* `parseGrantId`/`safeParseGrantId`. Ekklesia does not assume any particular format; Core's
* Postgres-backed store happens to generate a UUID, but callers must treat the value as opaque.
*/
export const grantIdSchema = z.string().trim().min(1).max(128).brand<"GrantId">();
export type GrantId = z.infer<typeof grantIdSchema>;
export function parseGrantId(input: unknown): GrantId {
return grantIdSchema.parse(input);
}
export function safeParseGrantId(input: unknown): z.ZodSafeParseResult<GrantId> {
return grantIdSchema.safeParse(input);
}
/**
* A generic, Core-opaque resource scope narrowing a grant or an authorization check to one
* resource (for example a specific calendar event or media item). Core never interprets the
* value beyond exact string equality — the owning module defines what a scope string means, if
* anything. Not branded: unlike a permission or a grant, a scope carries no identity of its own
* within Core; it is simply an opaque comparison key.
*/
export const authorizationScopeSchema = z.string().trim().min(1).max(256);
export type AuthorizationScope = z.infer<typeof authorizationScopeSchema>;
export function parseAuthorizationScope(input: unknown): AuthorizationScope {
return authorizationScopeSchema.parse(input);
}
export function safeParseAuthorizationScope(
input: unknown,
): z.ZodSafeParseResult<AuthorizationScope> {
return authorizationScopeSchema.safeParse(input);
}
/**
* Every permission declaration has exactly one baseline. `public` allows any principal, anonymous
* or authenticated. `authenticated` requires a valid authenticated principal and nothing more.
* `restricted` requires an active matching grant or platform-owner authority — baseline alone
* never satisfies it.
*/
export const permissionBaselineSchema = z.enum(["public", "authenticated", "restricted"]);
export type PermissionBaseline = z.infer<typeof permissionBaselineSchema>;
/**
* Distinguishes a grant an operator/system created directly (`explicit`) from one a module
* created as a side effect of its own business operation, attributed to some source the module
* understands (`relationship`) — for example adding a person to a volunteer roster. Core never
* interprets which relationship produced the grant; it only records the distinction and an
* opaque source reference for audit purposes.
*/
export const grantSourceKindSchema = z.enum(["explicit", "relationship"]);
export type GrantSourceKind = z.infer<typeof grantSourceKindSchema>;
/** A Core-opaque reference to whatever produced a `relationship` grant (or, optionally, extra
* context for an `explicit` one) — for example a module-defined identifier. Never interpreted by
* Core, never a foreign key into another module's tables. */
export const grantSourceRefSchema = z.string().trim().min(1).max(256);
export type GrantSourceRef = z.infer<typeof grantSourceRefSchema>;
const authorizationActorTypeSchema = z.enum(["user", "system"]);
export type AuthorizationActorType = z.infer<typeof authorizationActorTypeSchema>;
export interface AuthorizationUserActor {
readonly type: "user";
readonly userId: EkklesiaUserId;
}
export interface AuthorizationSystemActor {
readonly type: "system";
}
/**
* Who performed a security-relevant authorization mutation (a grant or platform-owner change).
* Mirrors `EkklesiaPrincipal`'s discriminated-union style. `system` covers Core-internal or
* automated actions with no specific human actor (for example a future bootstrap command run by
* an operator with no prior Ekklesia identity); it never means "unauthenticated" or "anonymous
* caller" — every mutating call site chooses this deliberately.
*/
export type AuthorizationActor = AuthorizationUserActor | AuthorizationSystemActor;
export const SYSTEM_ACTOR: AuthorizationSystemActor = Object.freeze({ type: "system" });
export function createUserActor(userId: unknown): AuthorizationUserActor {
return Object.freeze({ type: "user", userId: parseEkklesiaUserId(userId) });
}
export function isUserActor(actor: AuthorizationActor): actor is AuthorizationUserActor {
return actor.type === "user";
}
/**
* The only public result of an authorization check. Deliberately minimal and frozen: it never
* carries a reason, a matched grant, a permission echo, or any other detail a caller could use to
* infer another user's grants or Core's internal evaluation path. `AUTHORIZATION_ALLOWED` and
* `AUTHORIZATION_DENIED` are the only two values ever produced.
*/
export interface AuthorizationDecision {
readonly allowed: boolean;
}
export const AUTHORIZATION_ALLOWED: AuthorizationDecision = Object.freeze({ allowed: true });
export const AUTHORIZATION_DENIED: AuthorizationDecision = Object.freeze({ allowed: false });
export function isAuthorizationAllowed(decision: AuthorizationDecision): boolean {
return decision.allowed;
}
+21 -1
View File
@@ -16,11 +16,23 @@ export const capabilityIdSchema = z
.max(160) .max(160)
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier."); .regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier.");
/**
* Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId`: a plain
* `string` is not assignable to `PermissionId` without passing through
* `parsePermissionId`/`safeParsePermissionId`. The brand adds no runtime tag or wrapper — a value
* still serializes as a plain string. This is the one permission-identifier type shared by module
* manifest declarations (`moduleManifestSchema`'s `permissions` array) and Core's authorization
* evaluator, so a permission a module declares and a permission Core evaluates are provably the
* same identifier space. Namespacing (the required `.`-segments) is purely a naming convention for
* readability and collision-avoidance — it never implies hierarchy or inheritance during
* evaluation, and wildcard segments are not part of this format at all.
*/
export const permissionIdSchema = z export const permissionIdSchema = z
.string() .string()
.min(3) .min(3)
.max(160) .max(160)
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier."); .regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.")
.brand<"PermissionId">();
export const logicalBucketIdSchema = z export const logicalBucketIdSchema = z
.string() .string()
@@ -58,3 +70,11 @@ export type CapabilityId = z.infer<typeof capabilityIdSchema>;
export type PermissionId = z.infer<typeof permissionIdSchema>; export type PermissionId = z.infer<typeof permissionIdSchema>;
export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>; export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>;
export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>; export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>;
export function parsePermissionId(input: unknown): PermissionId {
return permissionIdSchema.parse(input);
}
export function safeParsePermissionId(input: unknown): z.ZodSafeParseResult<PermissionId> {
return permissionIdSchema.safeParse(input);
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./authorization.js";
export * from "./identifiers.js"; export * from "./identifiers.js";
export * from "./identity.js"; export * from "./identity.js";
export * from "./module-lifecycle.js"; export * from "./module-lifecycle.js";
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import {
AUTHORIZATION_ALLOWED,
AUTHORIZATION_DENIED,
type AuthorizationActor,
type AuthorizationDecision,
createUserActor,
type GrantId,
isAuthorizationAllowed,
isUserActor,
parseAuthorizationScope,
parseGrantId,
parsePermissionId,
type PermissionId,
safeParseAuthorizationScope,
safeParseGrantId,
safeParsePermissionId,
SYSTEM_ACTOR,
} from "../src/index.js";
describe("PermissionId", () => {
it("parses a valid lowercase dot-namespaced permission id", () => {
expect(parsePermissionId("calendar.events.read")).toBe("calendar.events.read");
});
it("is opaque at the type level: a plain string is not assignable without parsing", () => {
// @ts-expect-error a bare string must not satisfy the branded PermissionId type.
const notAllowed: PermissionId = "calendar.events.read";
expect(notAllowed).toBe("calendar.events.read");
});
it("rejects a non-namespaced, empty, oversized, or non-string permission id", () => {
expect(() => parsePermissionId("read")).toThrow();
expect(() => parsePermissionId("")).toThrow();
expect(() => parsePermissionId("Calendar.Events.Read")).toThrow();
expect(() => parsePermissionId("x".repeat(161))).toThrow();
expect(() => parsePermissionId(42)).toThrow();
expect(() => parsePermissionId(null)).toThrow();
});
it("rejects a wildcard segment: there is no such thing as a wildcard permission", () => {
expect(() => parsePermissionId("media.*")).toThrow();
expect(() => parsePermissionId("media.*.read")).toThrow();
expect(() => parsePermissionId("*")).toThrow();
});
it("exposes a safe-parse variant that never throws", () => {
expect(safeParsePermissionId("calendar.events.read").success).toBe(true);
expect(safeParsePermissionId("*").success).toBe(false);
});
});
describe("GrantId", () => {
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
expect(id).toBe("11111111-1111-1111-1111-111111111111");
expect(() => parseGrantId("")).toThrow();
expect(() => parseGrantId("x".repeat(129))).toThrow();
});
it("exposes a safe-parse variant that never throws", () => {
expect(safeParseGrantId("abc").success).toBe(true);
expect(safeParseGrantId("").success).toBe(false);
});
});
describe("AuthorizationScope", () => {
it("accepts a non-empty opaque scope and rejects empty/whitespace/oversized scope", () => {
expect(parseAuthorizationScope("calendar-event:123")).toBe("calendar-event:123");
expect(safeParseAuthorizationScope("").success).toBe(false);
expect(safeParseAuthorizationScope(" ").success).toBe(false);
expect(safeParseAuthorizationScope("x".repeat(257)).success).toBe(false);
});
});
describe("AuthorizationActor", () => {
it("creates a frozen user actor carrying only the opaque user id", () => {
const actor = createUserActor("user_123");
expect(actor).toEqual({ type: "user", userId: "user_123" });
expect(Object.isFrozen(actor)).toBe(true);
expect(isUserActor(actor)).toBe(true);
});
it("rejects an invalid user id when creating a user actor", () => {
expect(() => createUserActor("")).toThrow();
expect(() => createUserActor(42)).toThrow();
});
it("exposes a frozen system actor singleton distinguishable from a user actor", () => {
expect(SYSTEM_ACTOR).toEqual({ type: "system" });
expect(Object.isFrozen(SYSTEM_ACTOR)).toBe(true);
expect(isUserActor(SYSTEM_ACTOR)).toBe(false);
});
it("discriminates actors on type without leaking extra fields", () => {
const actors: AuthorizationActor[] = [SYSTEM_ACTOR, createUserActor("user_123")];
for (const actor of actors) {
expect(["user", "system"]).toContain(actor.type);
}
expect(Object.keys(SYSTEM_ACTOR)).toEqual(["type"]);
});
});
describe("AuthorizationDecision", () => {
it("exposes exactly two frozen, minimal decision singletons", () => {
expect(AUTHORIZATION_ALLOWED).toEqual({ allowed: true });
expect(AUTHORIZATION_DENIED).toEqual({ allowed: false });
expect(Object.isFrozen(AUTHORIZATION_ALLOWED)).toBe(true);
expect(Object.isFrozen(AUTHORIZATION_DENIED)).toBe(true);
expect(Object.keys(AUTHORIZATION_ALLOWED)).toEqual(["allowed"]);
expect(Object.keys(AUTHORIZATION_DENIED)).toEqual(["allowed"]);
});
it("never carries a reason, matched grant, permission echo, or any other detail", () => {
const decisions: AuthorizationDecision[] = [AUTHORIZATION_ALLOWED, AUTHORIZATION_DENIED];
for (const decision of decisions) {
expect(Object.keys(decision)).not.toContain("reason");
expect(Object.keys(decision)).not.toContain("grantId");
expect(Object.keys(decision)).not.toContain("permission");
expect(Object.keys(decision)).not.toContain("code");
}
});
it("exposes a boolean helper matching the allowed field", () => {
expect(isAuthorizationAllowed(AUTHORIZATION_ALLOWED)).toBe(true);
expect(isAuthorizationAllowed(AUTHORIZATION_DENIED)).toBe(false);
});
});
+2
View File
@@ -6,6 +6,8 @@ export default defineConfig({
hookTimeout: 30_000, hookTimeout: 30_000,
include: [ include: [
"core/tests/postgres-migration.integration.ts", "core/tests/postgres-migration.integration.ts",
"core/tests/authorization-store.integration.ts",
"core/tests/authorization-bootstrap.integration.ts",
"apps/api/tests/api-http.integration.ts", "apps/api/tests/api-http.integration.ts",
], ],
passWithNoTests: false, passWithNoTests: false,