feat: add authorization bootstrap and HTTP enforcement

Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
ekklesia
2026-07-23 11:16:32 -05:00
parent 72e082ff2f
commit 1363608552
33 changed files with 2962 additions and 63 deletions
+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);
});