phase-4/core-authorization #10

Merged
Zayin_Admin merged 4 commits from phase-4/core-authorization into main 2026-07-23 16:52:40 +00:00
33 changed files with 2962 additions and 63 deletions
Showing only changes of commit 1363608552 - Show all commits
+17 -6
View File
@@ -6,8 +6,8 @@ surfaces.
## Current status
Phase 3 is complete, and Phase 4A (Core identity foundation) and Phase 4B (identity HTTP
transport) have landed. The repository includes
Phase 3 is complete, Phase 4A (Core identity foundation) and Phase 4B (identity HTTP transport)
have landed, and Phase 4C (Core authorization) is in progress. The repository includes
public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
@@ -35,10 +35,20 @@ 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
[API](docs/development/api.md) and
[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** —
that, along with production approval-provider composition, jobs, physical storage provisioning,
surfaces, and feature modules, remains later vertical slices. Public registration is not yet ready
for a real public launch.
**No email verification, password recovery, or Dashboard exists yet** — that, along with
production approval-provider composition, jobs, physical storage provisioning, surfaces, and
feature modules, remains later vertical slices. Public registration is not yet ready for a real
public launch.
Phase 4C (in progress) 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).
## Quick start
@@ -146,6 +156,7 @@ environment.
- [Testing](docs/development/testing.md)
- [Identity](docs/development/identity.md)
- [API](docs/development/api.md)
- [Authorization](docs/development/authorization.md)
- [Module development](docs/module-development/README.md)
- [Architecture decisions](docs/architecture/decisions/README.md)
+27 -2
View File
@@ -1,13 +1,27 @@
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 { Hono } from "hono";
import type { Context } from "hono";
import { cors } from "hono/cors";
import { createGrantsRoutes } from "./grants-routes.js";
export interface CreateApiAppAuthorizationOptions {
readonly evaluator: AuthorizationEvaluator;
readonly store: AuthorizationStore;
}
export interface CreateApiAppOptions {
readonly authorization: CreateApiAppAuthorizationOptions;
readonly runtime: EkklesiaIdentityRuntime;
/** 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[];
}
@@ -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
* 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 {
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) => {
c.header("Cache-Control", "no-store");
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),
});
}
+212
View File
@@ -0,0 +1,212 @@
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);
}
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 { pathToFileURL } from "node:url";
import {
createIdentityDrizzleDatabase,
createIdentityRuntime,
type IdentityDatabaseHandle,
type IdentityRateLimitOverride,
} from "@ekklesia/core/identity";
createAuthorizationEvaluator,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
import { serve, type ServerType } from "@hono/node-server";
import type { Hono } from "hono";
import { createApiApp } from "./app.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
const connectionTimeoutMs = 10_000;
const operationTimeoutMs = 10_000;
@@ -26,7 +26,7 @@ export interface StartApiOptions {
readonly rateLimitOverride?: IdentityRateLimitOverride;
}
async function safeCloseDatabase(handle: IdentityDatabaseHandle): Promise<void> {
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
try {
await handle.close();
} 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
* identity runtime, and the Hono application, then starts the Node HTTP server. Returns a handle
* with an idempotent `shutdown()` so both the executable entry point and tests can stop the
* server and close the database pool deterministically. Never applies migrations or generates
* schema — the database must already be migrated through `pnpm migrations:apply`.
* Explicit runtime composition: constructs the one shared PostgreSQL pool (identity and
* authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity
* runtime, the Core authorization store and evaluator, and the Hono application, then starts the
* Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry
* point and tests can stop the server and close the shared pool deterministically. Never applies
* migrations or generates schema — the database must already be migrated through
* `pnpm migrations:apply`.
*/
export async function startApi(
config: ApiRuntimeConfig,
options: StartApiOptions = {},
): Promise<RunningApi> {
const databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, {
const databaseHandle = createApiDatabase(config.databaseUrl, {
connectionTimeoutMs,
operationTimeoutMs,
});
let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
try {
runtime = createIdentityRuntime({
config: config.identity,
db: databaseHandle.db,
db: databaseHandle.identity,
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
});
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
} catch (error) {
await safeCloseDatabase(databaseHandle);
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;
try {
+281
View File
@@ -2,6 +2,12 @@ import { randomBytes } from "node:crypto";
import { request as httpRequest } from "node:http";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
import {
bootstrapPlatformOwner,
createAuthorizationDrizzleDatabase,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
@@ -812,3 +818,278 @@ 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";
const testPermission = "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("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 type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { IdentityRuntimeError } from "@ekklesia/core/identity";
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 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", () => {
it("mounts Better Auth exactly under /api/auth/*", async () => {
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" });
@@ -29,7 +57,11 @@ describe("Ekklesia API Hono application", () => {
it("does not route requests outside /api/auth/* to Better Auth", async () => {
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" });
@@ -41,7 +73,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime({
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");
@@ -55,7 +91,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime({
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 body = (await response.json()) as Record<string, unknown>;
@@ -71,7 +111,11 @@ describe("Ekklesia API Hono application", () => {
.fn()
.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");
@@ -84,7 +128,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime({
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 body = await response.json();
@@ -96,7 +144,11 @@ describe("Ekklesia API Hono application", () => {
});
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");
@@ -105,7 +157,11 @@ describe("Ekklesia API Hono application", () => {
});
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", {
headers: { origin: trustedOrigin },
@@ -116,7 +172,11 @@ describe("Ekklesia API Hono application", () => {
});
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", {
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 () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
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 () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: {
@@ -149,7 +217,11 @@ describe("Ekklesia API Hono application", () => {
});
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", {
headers: { origin: trustedOrigin },
@@ -158,10 +230,41 @@ describe("Ekklesia API Hono application", () => {
expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
});
// "No generalized session/user object stored in Hono context" is verified by construction
// rather than by a black-box context probe: unlike Better Auth's own documented Hono example
// (which hydrates `c.set("user", session.user)` on every request), createApiApp never calls
// `c.set` at all — the only per-request identity data that ever exists is the narrow
// EkklesiaPrincipal returned directly from the /api/v1/principal handler above, whose exact
// shape (kind + userId only) is already asserted by the principal-shape tests in this file.
// Unlike Better Auth's own documented Hono example (which hydrates `c.set("user", session.user)`
// on every request), createApiApp only ever calls `c.set` once, for the narrow EkklesiaPrincipal
// (kind + userId only), and only on routes `requirePermission` actually protects — proven by the
// grants-routes tests, not by a black-box context probe here. `/api/v1/principal` itself still
// never touches `c.set`; its response shape is asserted directly above.
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" });
});
});
+350
View File
@@ -0,0 +1,350 @@
import {
createAuthenticatedPrincipal,
parseAuthorizationScope,
parseEkklesiaUserId,
parseGrantId,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
AuthorizationStoreError,
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");
const permission = parsePermissionId("calendar.events.read");
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,
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, 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,
scope: "calendar-event:123",
sourceKind: "explicit",
userId: subjectUserId,
}),
);
});
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, 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);
});
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 () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
@@ -205,6 +225,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
code: "IDENTITY_CONSTRUCTION_FAILED",
});
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
@@ -219,6 +240,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
await expect(startApi(validConfig())).rejects.toThrow();
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
+4
View File
@@ -30,6 +30,10 @@
"./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": {
+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");
}
}
+11
View File
@@ -105,3 +105,14 @@ export function createAuthorizationDrizzleDatabase(
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 });
}
+2
View File
@@ -1,4 +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,
);
+51 -2
View File
@@ -14,7 +14,7 @@ import {
parseGrantId,
parsePermissionId,
} from "@ekklesia/contracts";
import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import type {
ActiveGrantQuery,
@@ -29,6 +29,7 @@ import {
export type AuthorizationStoreErrorCode =
| "AUTHORIZATION_GRANT_CREATE_FAILED"
| "AUTHORIZATION_GRANT_LIST_FAILED"
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
| "AUTHORIZATION_GRANT_NOT_FOUND"
| "AUTHORIZATION_GRANT_REVOKE_FAILED"
@@ -38,6 +39,7 @@ export type AuthorizationStoreErrorCode =
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.",
@@ -90,6 +92,18 @@ export interface GrantRecord {
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;
}
/**
@@ -103,6 +117,12 @@ export interface RevokeGrantInput {
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>;
}
@@ -193,7 +213,13 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
revokedByActorType: input.actor.type,
})
.where(
and(eq(ekklesiaCoreGrant.id, input.grantId), isNull(ekklesiaCoreGrant.revokedAt)),
and(
eq(ekklesiaCoreGrant.id, input.grantId),
isNull(ekklesiaCoreGrant.revokedAt),
...(input.sourceKind === undefined
? []
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
),
)
.returning();
if (!row) {
@@ -243,6 +269,29 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
}
},
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,
+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,
});
}
/**
* 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 });
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./authorization/index.js";
export * from "./database/index.js";
export * from "./diagnostics/index.js";
export * from "./identity/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.
});
});
+18
View File
@@ -1,3 +1,4 @@
import type { Pool } from "pg";
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
@@ -7,6 +8,7 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
import {
AuthorizationDatabaseError,
createAuthorizationDrizzleDatabase,
createAuthorizationDrizzleView,
} from "../src/authorization/database.js";
describe("Core authorization Drizzle database composition", () => {
@@ -144,3 +146,19 @@ describe("Core authorization Drizzle database composition", () => {
).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();
});
});
@@ -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,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";
const poolConstructor = vi.hoisted(() => vi.fn());
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", () => {
it("does not construct a pool merely because the module is imported", () => {
@@ -141,3 +146,19 @@ describe("Core identity Drizzle database composition", () => {
).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();
});
});
+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
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).
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
@@ -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`, ...). |
| `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
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,
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,
auditable operator command that grants an *existing* identity platform-owner authority — signup
order is never part of that decision. Future **relationship-derived access** (for example, a
person added to a volunteer roster automatically gaining narrow, reversible read access to that
roster) belongs to a later roster slice, not this one.
Explicit **platform-owner bootstrap** is now available as a local operator command — see
[Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity
platform-owner authority; signup order is never part of that decision. Future
**relationship-derived access** (for example, a person added to a volunteer roster automatically
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**
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
`apps/api/src/server.ts` configures the identity PostgreSQL pool (`createIdentityDrizzleDatabase`,
`core/src/identity/database.ts`) with both a connection timeout and an operation timeout (10
seconds each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts`
already uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does
nothing once a query is already running on one. The operation timeout additionally sets `pg`'s
`query_timeout` and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than
holding a pool connection (and its caller) indefinitely. An invalid timeout (non-integer, zero,
negative, or 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.
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
`apps/api/src/database.ts`, built on `createValidatedPostgresPool` from
`core/src/database/pool.ts`) with both a connection timeout and an operation timeout (10 seconds
each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` already
uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does nothing once
a query is already running on one. The operation timeout additionally sets `pg`'s `query_timeout`
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
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
@@ -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
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
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
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
@@ -268,7 +278,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
## Next phase
Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants /
platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible
relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model
that does not exist yet.
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
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.
+299
View File
@@ -0,0 +1,299 @@
# 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 and [API](api.md) for the
HTTP transport it is mounted into.
## 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).
## 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`. |
| `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`, or the permission id was unregistered. |
| `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, exact server-derived-argument assertions, sourceKind-filtered
revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping.
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above.
- `apps/api/tests/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 create/list/revoke cycle with audit-trail
verification, 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).
+1
View File
@@ -11,6 +11,7 @@
"packageManager": "[email protected]",
"scripts": {
"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",
"build": "turbo run build",
"ci:verify": "pnpm verify && pnpm infrastructure:config",
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
include: [
"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",
],
passWithNoTests: false,