diff --git a/README.md b/README.md index eeb42cf..2c6e33b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 166b01f..8acd5fa 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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); diff --git a/apps/api/src/authorization-middleware.ts b/apps/api/src/authorization-middleware.ts new file mode 100644 index 0000000..813d184 --- /dev/null +++ b/apps/api/src/authorization-middleware.ts @@ -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): 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; + }; +} diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts new file mode 100644 index 0000000..b8b6b10 --- /dev/null +++ b/apps/api/src/database.ts @@ -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; +} + +/** + * 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), + }); +} diff --git a/apps/api/src/grants-routes.ts b/apps/api/src/grants-routes.ts new file mode 100644 index 0000000..78fdd4d --- /dev/null +++ b/apps/api/src/grants-routes.ts @@ -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; +} + +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; + 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; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index e492ab4..c4b2760 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -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 { +async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise { try { await handle.close(); } catch { @@ -54,34 +54,47 @@ function bindServer(app: Hono, host: string, port: number): Promise } /** - * 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 { - const databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, { + const databaseHandle = createApiDatabase(config.databaseUrl, { connectionTimeoutMs, operationTimeoutMs, }); let runtime: ReturnType; + let authorizationStore: ReturnType; 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 { diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index cb27822..652e291 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -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 = "authz-owner@example.test"; + const managerEmail = "authz-manager@example.test"; + 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, "authz-infra@example.test", "correct horse battery staple"); + const { cookiePair } = await signIn( + origin, + "authz-infra@example.test", + "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); + }); +}); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index a9e9821..d2d590c 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -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 = {}): 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; + readonly store?: Partial; +}): 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; @@ -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(); + }); + }); }); diff --git a/apps/api/tests/authorization-middleware.test.ts b/apps/api/tests/authorization-middleware.test.ts new file mode 100644 index 0000000..9c42136 --- /dev/null +++ b/apps/api/tests/authorization-middleware.test.ts @@ -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 { + return { + handleAuthRequest: vi.fn(), + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + ...overrides, + }; +} + +function fakeEvaluator(overrides: Partial = {}): AuthorizationEvaluator { + return { + authorize: vi.fn().mockResolvedValue({ allowed: true }), + ...overrides, + }; +} + +function buildTestApp(options: Partial = {}) { + 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" }); + }); +}); diff --git a/apps/api/tests/grants-routes.test.ts b/apps/api/tests/grants-routes.test.ts new file mode 100644 index 0000000..0cd368f --- /dev/null +++ b/apps/api/tests/grants-routes.test.ts @@ -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 { + return { + handleAuthRequest: vi.fn(), + resolvePrincipal: vi.fn().mockResolvedValue(manager), + ...overrides, + }; +} + +function fakeStore(overrides: Partial = {}): 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" }); + }); +}); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index e52d31d..6e5a3dd 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -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); }); diff --git a/core/package.json b/core/package.json index 2bb96bb..1f46033 100644 --- a/core/package.json +++ b/core/package.json @@ -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": { diff --git a/core/src/authorization/bootstrap.ts b/core/src/authorization/bootstrap.ts new file mode 100644 index 0000000..ab18850 --- /dev/null +++ b/core/src/authorization/bootstrap.ts @@ -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> = + 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 { + 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"); + } +} diff --git a/core/src/authorization/database.ts b/core/src/authorization/database.ts index db18ec8..8050614 100644 --- a/core/src/authorization/database.ts +++ b/core/src/authorization/database.ts @@ -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 }); +} diff --git a/core/src/authorization/index.ts b/core/src/authorization/index.ts index c4f6a05..272b3ad 100644 --- a/core/src/authorization/index.ts +++ b/core/src/authorization/index.ts @@ -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"; diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts new file mode 100644 index 0000000..66e52ce --- /dev/null +++ b/core/src/authorization/permissions.ts @@ -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(); + 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, +); diff --git a/core/src/authorization/store.ts b/core/src/authorization/store.ts index 2dd2f8e..0946539 100644 --- a/core/src/authorization/store.ts +++ b/core/src/authorization/store.ts @@ -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> = 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; revokeGrant(input: RevokeGrantInput): Promise; + /** 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; grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise; revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise; } @@ -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 => { + 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, diff --git a/core/src/cli/bootstrap-platform-owner.ts b/core/src/cli/bootstrap-platform-owner.ts new file mode 100644 index 0000000..4be74c7 --- /dev/null +++ b/core/src/cli/bootstrap-platform-owner.ts @@ -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 --confirm-user-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 { + 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; + }); +} diff --git a/core/src/database/index.ts b/core/src/database/index.ts new file mode 100644 index 0000000..8636161 --- /dev/null +++ b/core/src/database/index.ts @@ -0,0 +1 @@ +export * from "./pool.js"; diff --git a/core/src/database/pool.ts b/core/src/database/pool.ts new file mode 100644 index 0000000..0273141 --- /dev/null +++ b/core/src/database/pool.ts @@ -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; + 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, + }); +} diff --git a/core/src/identity/database.ts b/core/src/identity/database.ts index 5f9b110..ff68722 100644 --- a/core/src/identity/database.ts +++ b/core/src/identity/database.ts @@ -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 }); +} diff --git a/core/src/index.ts b/core/src/index.ts index a5c889e..1a9ca00 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -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"; diff --git a/core/tests/authorization-bootstrap.integration.ts b/core/tests/authorization-bootstrap.integration.ts new file mode 100644 index 0000000..246cb94 --- /dev/null +++ b/core/tests/authorization-bootstrap.integration.ts @@ -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(); +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 { + 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( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +async function createDatabase(databaseName: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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["db"]; + let close: () => Promise; + + 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, "bootstrap-one@example.test"); + await seedUser(admin, secondUserId, "bootstrap-two@example.test"); + + 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, "bootstrap-race-a@example.test"); + await seedUser(admin, raceUserB, "bootstrap-race-b@example.test"); + + 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); + } + }); +}); diff --git a/core/tests/authorization-bootstrap.test.ts b/core/tests/authorization-bootstrap.test.ts new file mode 100644 index 0000000..e87c25b --- /dev/null +++ b/core/tests/authorization-bootstrap.test.ts @@ -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. + }); +}); diff --git a/core/tests/authorization-database.test.ts b/core/tests/authorization-database.test.ts index cb8f067..e5dba33 100644 --- a/core/tests/authorization-database.test.ts +++ b/core/tests/authorization-database.test.ts @@ -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(); + }); +}); diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts new file mode 100644 index 0000000..fb4574a --- /dev/null +++ b/core/tests/authorization-permissions.test.ts @@ -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); + }); +}); diff --git a/core/tests/cli-bootstrap-platform-owner.test.ts b/core/tests/cli-bootstrap-platform-owner.test.ts new file mode 100644 index 0000000..eb069bd --- /dev/null +++ b/core/tests/cli-bootstrap-platform-owner.test.ts @@ -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; + }); +}); diff --git a/core/tests/database-pool.test.ts b/core/tests/database-pool.test.ts new file mode 100644 index 0000000..1dad7e1 --- /dev/null +++ b/core/tests/database-pool.test.ts @@ -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:do-not-log@db.example.test/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:do-not-log@example.test/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(); + }); +}); diff --git a/core/tests/identity-database.test.ts b/core/tests/identity-database.test.ts index 5e07508..a7440c8 100644 --- a/core/tests/identity-database.test.ts +++ b/core/tests/identity-database.test.ts @@ -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(); + }); +}); diff --git a/docs/development/api.md b/docs/development/api.md index 9710b10..699d324 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -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= ## 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. diff --git a/docs/development/authorization.md b/docs/development/authorization.md new file mode 100644 index 0000000..ad7d996 --- /dev/null +++ b/docs/development/authorization.md @@ -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 \ + --confirm-user-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=` | 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). diff --git a/package.json b/package.json index 5a90f9a..fc3a493 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "packageManager": "pnpm@11.12.0", "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", diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 61a3a88..49e7c36 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -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,