diff --git a/README.md b/README.md index eeb42cf..dfc62b1 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, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), +and Phase 4C (Core authorization) have landed. 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,21 @@ still cannot reach any of this. Public email/password registration is intentiona receives identical lowest-privilege baseline status and signup order never grants elevation. See [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 adds Core's authorization foundation on top of identity: a schema, evaluator, and store +for platform owners, explicit grants, and relationship-derived grants; a smallest-possible +immutable permission registry; an explicit, single-use, concurrency-safe platform-owner bootstrap +command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup +order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped +`/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin` +on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, service +accounts/API tokens, and module-permission registration remain later work. See +[Authorization](docs/development/authorization.md) and +[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md). ## Quick start @@ -83,8 +94,9 @@ Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 1 migration · checksums valid + 2 migrations · checksums valid core:0000_identity_core_tables · f8f2086ef06f + core:0001_authorization_core_tables · 830e1fbc26d0 PostgreSQL ○ Connection check skipped @@ -146,6 +158,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..2f3d0c0 --- /dev/null +++ b/apps/api/src/grants-routes.ts @@ -0,0 +1,222 @@ +import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; +import { + createUserActor, + isAuthenticatedPrincipal, + safeParseAuthorizationScope, + safeParseEkklesiaUserId, + safeParseGrantId, + safeParsePermissionId, +} from "@ekklesia/contracts"; +import { + type AuthorizationStore, + AuthorizationStoreError, + CORE_AUTHORIZATION_GRANTS_MANAGE, + type GrantRecord, +} from "@ekklesia/core/authorization"; +import { Hono } from "hono"; +import type { Context } from "hono"; +import { + type AuthorizationMiddlewareOptions, + getAuthorizedPrincipal, + requirePermission, + requireTrustedOrigin, +} from "./authorization-middleware.js"; + +export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions { + readonly store: AuthorizationStore; + readonly trustedOrigins: ReadonlySet; +} + +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); + } + + // A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry + // (never the request) decides what may be granted — the same registry `requireManagePermission` + // already consults for the caller's own `core.authorization.grants.manage` check — so a caller + // cannot pre-create a dormant grant for an invented identifier that later becomes effective the + // moment someone registers it. Sanitized identically to any other denial: this must never reveal + // whether the identifier was malformed (400, above) or simply unregistered (403, here). + if (!options.registry.has(parsed.permission)) { + return c.json({ code: "AUTHORIZATION_DENIED" }, 403); + } + + try { + const grant = await options.store.createGrant({ + actor: createUserActor(principal.userId), + ...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }), + permission: parsed.permission, + ...(parsed.scope === undefined ? {} : { scope: parsed.scope }), + sourceKind: "explicit", + userId: parsed.subjectUserId, + }); + return c.json({ grant: serializeGrant(grant) }, 201); + } catch (error) { + return sanitizedStoreErrorResponse(c, error); + } + }); + + routes.post( + "/api/v1/grants/:grantId/revoke", + requireOrigin, + requireManagePermission, + async (c) => { + c.header("Cache-Control", "no-store"); + const principal = getAuthorizedPrincipal(c); + if (!isAuthenticatedPrincipal(principal)) { + return c.json({ code: "AUTHORIZATION_DENIED" }, 403); + } + + const grantId = safeParseGrantId(c.req.param("grantId")); + if (!grantId.success) { + return c.json({ code: "INVALID_REQUEST" }, 400); + } + + try { + await options.store.revokeGrant({ + actor: createUserActor(principal.userId), + grantId: grantId.data, + sourceKind: "explicit", + }); + return c.json({ success: true }, 200); + } catch (error) { + return sanitizedStoreErrorResponse(c, error); + } + }, + ); + + return routes; +} 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 06b4d70..c997385 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, @@ -174,13 +180,18 @@ async function applyCoreMigrations(url: string): Promise { platformVersion: "0.1.0", }, }); - // Phase 4A's committed migration must not have drifted while building this transport slice. + // Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building + // this transport slice. expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ "core:0000_identity_core_tables", + "core:0001_authorization_core_tables", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", ); + expect(aggregate.migrations[1]?.checksum).toBe( + "830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e", + ); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { await rm(emptyModulesDirectory, { recursive: true, force: true }); @@ -807,3 +818,332 @@ describe.sequential("live API — Better Auth logging never leaks operational de } }); }); + +describe.sequential("live API — platform owner and delegated grant management", () => { + let database: { name: string; url: string }; + let running: RunningApi; + let origin: string; + let ownerUserId: string; + let ownerCookie: string; + let managerUserId: string; + let managerCookie: string; + let createdGrantId: string; + + const ownerEmail = "authz-owner@example.test"; + const managerEmail = "authz-manager@example.test"; + const password = "correct horse battery staple"; + // Module permission registration does not exist yet, so `core.authorization.grants.manage` — + // the one permission `CORE_PERMISSION_REGISTRY` actually declares — stands in for "some + // registered permission" throughout this scenario's explicit-grant lifecycle. + const testPermission = "core.authorization.grants.manage"; + // Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the + // live route rejects it even for a caller who otherwise has full grant-management authority. + const unregisteredPermission = "calendar.events.read"; + + it("starts a freshly migrated API and signs up the eventual owner and eventual delegated manager", async () => { + database = await createDisposableDatabase("authz_http"); + await applyCoreMigrations(database.url); + running = await startMigratedApi(database.url); + origin = apiOrigin(running); + + const ownerSignUp = await signUp(origin, ownerEmail, password); + ownerUserId = ownerSignUp.body.user.id; + const managerSignUp = await signUp(origin, managerEmail, password); + managerUserId = managerSignUp.body.user.id; + + const ownerSignIn = await signIn(origin, ownerEmail, password); + expect(ownerSignIn.cookiePair).toBeDefined(); + ownerCookie = ownerSignIn.cookiePair as string; + }); + + it("denies grants access to an anonymous caller", async () => { + const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + }); + + it("denies grants access to an authenticated caller with no owner authority and no grant", async () => { + const managerSignInBefore = await signIn(origin, managerEmail, password); + const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { + headers: { cookie: managerSignInBefore.cookiePair as string }, + }); + expect(response.status).toBe(403); + }); + + it("bootstraps the owner via Core's real bootstrap operation, then confirms owner authority over the HTTP grants API", async () => { + // Exactly what `pnpm authorization:bootstrap-owner` would do, driven directly against the + // same disposable database the running API already uses — no second process needed to prove + // the HTTP layer honors real platform-owner state. + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) }); + } finally { + await close(); + } + + const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { + headers: { cookie: ownerCookie }, + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ grants: [] }); + }); + + it("lets the owner delegate grants.manage to another account through the explicit-grant HTTP API", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ + permission: "core.authorization.grants.manage", + subjectUserId: managerUserId, + }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + const body = (await response.json()) as { grant: { sourceKind: string; userId: string } }; + + expect(response.status).toBe(201); + expect(body.grant.sourceKind).toBe("explicit"); + expect(body.grant.userId).toBe(managerUserId); + }); + + it("lets the delegated manager — who is not a platform owner — manage another user's grants", async () => { + const managerSignIn = await signIn(origin, managerEmail, password); + expect(managerSignIn.cookiePair).toBeDefined(); + managerCookie = managerSignIn.cookiePair as string; + + const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { + headers: { cookie: managerCookie }, + }); + expect(response.status).toBe(200); + }); + + it("rejects a well-formed but unregistered permission even from a caller with full grant-management authority, and writes no grant or audit row", async () => { + const admin = await openAdministrativeClient(database.url); + try { + const grantCountBefore = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`, + [unregisteredPermission], + ); + const auditCountBefore = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`, + [unregisteredPermission], + ); + expect(grantCountBefore[0]?.count).toBe("0"); + expect(auditCountBefore[0]?.count).toBe("0"); + + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ + permission: unregisteredPermission, + subjectUserId: managerUserId, + }), + headers: { + "content-type": "application/json", + cookie: managerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + + const grantCountAfter = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`, + [unregisteredPermission], + ); + const auditCountAfter = await safeQuery<{ count: string }>( + admin, + `select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`, + [unregisteredPermission], + ); + expect(grantCountAfter[0]?.count).toBe("0"); + expect(auditCountAfter[0]?.count).toBe("0"); + } finally { + await safeEnd(admin); + } + }); + + it("creates an explicit grant via HTTP, deriving sourceKind and actor server-side, and records an atomic audit entry", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ + permission: testPermission, + scope: "calendar-event:1", + subjectUserId: managerUserId, + }), + headers: { + "content-type": "application/json", + cookie: managerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + const body = (await response.json()) as { + grant: { id: string; sourceKind: string; userId: string }; + }; + + expect(response.status).toBe(201); + expect(body.grant.sourceKind).toBe("explicit"); + expect(body.grant.userId).toBe(managerUserId); + createdGrantId = body.grant.id; + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ action: string; actor_id: string | null }>( + admin, + `select action, actor_id from "ekklesia_core_authorization_audit" where grant_id = $1`, + [createdGrantId], + ); + expect(rows).toEqual([{ action: "grant.create", actor_id: managerUserId }]); + } finally { + await safeEnd(admin); + } + }); + + it("lists the explicit grant via HTTP", async () => { + const response = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, { + headers: { cookie: ownerCookie }, + }); + const body = (await response.json()) as { grants: Array<{ id: string }> }; + + expect(response.status).toBe(200); + expect(body.grants.some((grant) => grant.id === createdGrantId)).toBe(true); + }); + + it("rejects a create-grant request carrying disallowed fields such as actor or sourceKind, even alongside otherwise-valid fields", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ + actor: { type: "system" }, + permission: testPermission, + sourceKind: "relationship", + subjectUserId: managerUserId, + }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(400); + }); + + it("rejects a mutation request with no trusted Origin header, and never returns a permissive CORS header", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }), + headers: { "content-type": "application/json", cookie: ownerCookie }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("rejects a mutation request from an untrusted Origin the same way", async () => { + const response = await fetch(`${origin}/api/v1/grants`, { + body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }), + headers: { + "content-type": "application/json", + cookie: ownerCookie, + origin: untrustedOrigin, + }, + method: "POST", + }); + expect(response.status).toBe(403); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("revokes the explicit grant via HTTP and records the revocation audit entry", async () => { + const response = await fetch(`${origin}/api/v1/grants/${createdGrantId}/revoke`, { + headers: { cookie: ownerCookie, origin: dashboardOrigin }, + method: "POST", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ action: string }>( + admin, + `select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`, + [createdGrantId], + ); + expect(rows.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]); + } finally { + await safeEnd(admin); + } + }); + + it("never exposes a relationship-derived grant through the generic list, create, or revoke HTTP API", async () => { + // Seeded directly through the store, simulating a module's own business operation — never + // through HTTP, which has no way to express sourceKind at all. + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + let relationshipGrantId: string; + try { + const store = createAuthorizationStore(db); + const relationshipGrant = await store.createGrant({ + actor: SYSTEM_ACTOR, + permission: parsePermissionId("roster.volunteer.read"), + sourceKind: "relationship", + sourceRef: "roster:volunteer:99", + userId: parseEkklesiaUserId(managerUserId), + }); + relationshipGrantId = relationshipGrant.id; + } finally { + await close(); + } + + const listResponse = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, { + headers: { cookie: ownerCookie }, + }); + const listBody = (await listResponse.json()) as { grants: Array<{ id: string }> }; + expect(listBody.grants.some((grant) => grant.id === relationshipGrantId)).toBe(false); + + const revokeResponse = await fetch(`${origin}/api/v1/grants/${relationshipGrantId}/revoke`, { + headers: { cookie: ownerCookie, origin: dashboardOrigin }, + method: "POST", + }); + expect(revokeResponse.status).toBe(404); + expect(await revokeResponse.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" }); + }); + + it("shuts down cleanly, closing the one shared pool", async () => { + await running.shutdown(); + const remainingConnections = await activeConnectionCount(database.name); + expect(remainingConnections).toBe(0); + await dropDatabase(database.name); + }); +}); + +describe.sequential("live API — authorization infrastructure failure is never downgraded to a false denial", () => { + it("returns a sanitized 503, not a 403, when the database becomes unreachable mid-session", async () => { + const database = await createDisposableDatabase("authz_infra_failure"); + await applyCoreMigrations(database.url); + const running = await startMigratedApi(database.url); + const origin = apiOrigin(running); + + await signUp(origin, "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..0b1b37c --- /dev/null +++ b/apps/api/tests/grants-routes.test.ts @@ -0,0 +1,375 @@ +import { + createAuthenticatedPrincipal, + parseAuthorizationScope, + parseEkklesiaUserId, + parseGrantId, + parsePermissionId, +} from "@ekklesia/contracts"; +import { + type AuthorizationEvaluator, + type AuthorizationStore, + AuthorizationStoreError, + CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_PERMISSION_REGISTRY, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { Hono } from "hono"; +import { describe, expect, it, vi } from "vitest"; +import { createGrantsRoutes } from "../src/grants-routes.js"; + +const trustedOrigin = "https://dashboard.example.test"; +const untrustedOrigin = "https://evil.example.test"; +const manager = createAuthenticatedPrincipal("manager_1"); +const subjectUserId = parseEkklesiaUserId("subject_1"); +// Well-formed but never declared in `CORE_PERMISSION_REGISTRY` — used to prove the routes reject +// (or, for listing, still return) an unregistered permission id rather than assuming registration. +const permission = parsePermissionId("calendar.events.read"); +// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach +// `store.createGrant`/`store.revokeGrant` can do so without also depending on module permission +// registration, which does not exist yet. +const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE; +const grantId = parseGrantId("11111111-1111-1111-1111-111111111111"); + +function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { + 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: registeredPermission, + scope: parseAuthorizationScope("calendar-event:123"), + sourceKind: "explicit" as const, + userId: subjectUserId, + }; + const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) }); + const { app } = buildApp({ store }); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ + permission: registeredPermission, + scope: "calendar-event:123", + subjectUserId, + }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }); + const body = (await response.json()) as { grant: { id: string } }; + + expect(response.status).toBe(201); + expect(body.grant.id).toBe(grantId); + expect(store.createGrant).toHaveBeenCalledWith( + expect.objectContaining({ + actor: { type: "user", userId: manager.userId }, + permission: registeredPermission, + scope: "calendar-event:123", + sourceKind: "explicit", + userId: subjectUserId, + }), + ); + }); + + it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => { + const { app, store } = buildApp({}); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ permission, subjectUserId }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + expect(store.createGrant).not.toHaveBeenCalled(); + }); + + it.each([ + "actor", + "owner", + "isOwner", + "sourceKind", + "sourceRef", + "id", + "grantId", + "createdAt", + "revokedAt", + "createdByActorId", + "effectivePermissions", + ])("rejects a request body carrying a disallowed field (%s), even alongside otherwise-valid fields", async (field) => { + const { app, store } = buildApp({}); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ [field]: "hostile-value", permission, subjectUserId }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(400); + expect(store.createGrant).not.toHaveBeenCalled(); + }); + + it("rejects a malformed permission, subject id, scope, or expiresAt", async () => { + const { app, store } = buildApp({}); + + const responses = await Promise.all( + [ + { permission: "not-namespaced", subjectUserId }, + { permission, subjectUserId: "" }, + { permission, scope: "", subjectUserId }, + { expiresAt: "not-a-date", permission, subjectUserId }, + ].map((body) => + app.request("/api/v1/grants", { + body: JSON.stringify(body), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }), + ), + ); + + for (const response of responses) { + expect(response.status).toBe(400); + } + expect(store.createGrant).not.toHaveBeenCalled(); + }); + + it("maps a store infrastructure failure to a sanitized 503", async () => { + const store = fakeStore({ + createGrant: vi + .fn() + .mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED")), + }); + const { app } = buildApp({ store }); + + const response = await app.request("/api/v1/grants", { + body: JSON.stringify({ permission: registeredPermission, subjectUserId }), + headers: { "content-type": "application/json", origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" }); + }); +}); + +describe("POST /api/v1/grants/:grantId/revoke", () => { + it("rejects a request with no trusted Origin header before ever checking permission", async () => { + const { app, evaluator } = buildApp({}); + + const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { method: "POST" }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + expect(evaluator.authorize).not.toHaveBeenCalled(); + }); + + it("revokes an explicit grant, deriving the actor server-side from the authenticated principal", async () => { + const store = fakeStore({ revokeGrant: vi.fn().mockResolvedValue(undefined) }); + const { app } = buildApp({ store }); + + const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { + headers: { origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + expect(store.revokeGrant).toHaveBeenCalledWith({ + actor: { type: "user", userId: manager.userId }, + grantId, + sourceKind: "explicit", + }); + }); + + it("always constrains revocation to sourceKind explicit, so a relationship-derived grant can never be targeted by id", async () => { + // The store itself enforces this by treating a sourceKind mismatch exactly like a missing + // grant; this test proves the route always asks for that constraint, and correctly surfaces + // the resulting "not found" as 404 rather than leaking that the grant exists but is the wrong + // kind. + const store = fakeStore({ + revokeGrant: vi + .fn() + .mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")), + }); + const { app } = buildApp({ store }); + + const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { + headers: { origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" }); + expect(store.revokeGrant).toHaveBeenCalledWith( + expect.objectContaining({ sourceKind: "explicit" }), + ); + }); + + it("rejects a malformed grant id", async () => { + const { app, store } = buildApp({}); + + const response = await app.request("/api/v1/grants/%20/revoke", { + headers: { origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(400); + expect(store.revokeGrant).not.toHaveBeenCalled(); + }); + + it("maps a store infrastructure failure to a sanitized 503", async () => { + const store = fakeStore({ + revokeGrant: vi + .fn() + .mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED")), + }); + const { app } = buildApp({ store }); + + const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { + headers: { origin: trustedOrigin }, + method: "POST", + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" }); + }); +}); 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/drizzle.config.ts b/core/drizzle.config.ts index c7436db..77a35de 100644 --- a/core/drizzle.config.ts +++ b/core/drizzle.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "postgresql", - schema: "./src/identity/schema.ts", + schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"], out: "./drizzle", migrations: { prefix: "index", diff --git a/core/drizzle/0001_authorization_core_tables.sql b/core/drizzle/0001_authorization_core_tables.sql new file mode 100644 index 0000000..4964877 --- /dev/null +++ b/core/drizzle/0001_authorization_core_tables.sql @@ -0,0 +1,51 @@ +CREATE TABLE "ekklesia_core_authorization_audit" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "action" text NOT NULL, + "actor_type" text NOT NULL, + "actor_id" text, + "subject_user_id" text NOT NULL, + "permission_id" text, + "scope" text, + "grant_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "ekklesia_core_authorization_audit_action_check" CHECK ("ekklesia_core_authorization_audit"."action" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')), + CONSTRAINT "ekklesia_core_authorization_audit_actor_type_check" CHECK ("ekklesia_core_authorization_audit"."actor_type" in ('user', 'system')), + CONSTRAINT "ekklesia_core_authorization_audit_scope_check" CHECK ("ekklesia_core_authorization_audit"."scope" is null or length("ekklesia_core_authorization_audit"."scope") > 0) +); +--> statement-breakpoint +CREATE TABLE "ekklesia_core_grant" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "permission_id" text NOT NULL, + "scope" text, + "source_kind" text NOT NULL, + "source_ref" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_by_actor_type" text NOT NULL, + "created_by_actor_id" text, + "expires_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "revoked_by_actor_type" text, + "revoked_by_actor_id" text, + CONSTRAINT "ekklesia_core_grant_source_kind_check" CHECK ("ekklesia_core_grant"."source_kind" in ('explicit', 'relationship')), + CONSTRAINT "ekklesia_core_grant_created_actor_type_check" CHECK ("ekklesia_core_grant"."created_by_actor_type" in ('user', 'system')), + CONSTRAINT "ekklesia_core_grant_revoked_actor_type_check" CHECK ("ekklesia_core_grant"."revoked_by_actor_type" is null or "ekklesia_core_grant"."revoked_by_actor_type" in ('user', 'system')) +); +--> statement-breakpoint +CREATE TABLE "ekklesia_core_platform_owner" ( + "user_id" text PRIMARY KEY NOT NULL, + "granted_at" timestamp with time zone DEFAULT now() NOT NULL, + "granted_by_actor_type" text NOT NULL, + "granted_by_actor_id" text, + "revoked_at" timestamp with time zone, + "revoked_by_actor_type" text, + "revoked_by_actor_id" text, + CONSTRAINT "ekklesia_core_platform_owner_granted_actor_type_check" CHECK ("ekklesia_core_platform_owner"."granted_by_actor_type" in ('user', 'system')), + CONSTRAINT "ekklesia_core_platform_owner_revoked_actor_type_check" CHECK ("ekklesia_core_platform_owner"."revoked_by_actor_type" is null or "ekklesia_core_platform_owner"."revoked_by_actor_type" in ('user', 'system')) +); +--> statement-breakpoint +ALTER TABLE "ekklesia_core_authorization_audit" ADD CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ekklesia_core_grant" ADD CONSTRAINT "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ekklesia_core_platform_owner" ADD CONSTRAINT "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "ekklesia_core_authorization_audit_subject_idx" ON "ekklesia_core_authorization_audit" USING btree ("subject_user_id");--> statement-breakpoint +CREATE INDEX "ekklesia_core_grant_user_permission_idx" ON "ekklesia_core_grant" USING btree ("user_id","permission_id"); \ No newline at end of file diff --git a/core/drizzle/meta/0001_snapshot.json b/core/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..80f55ee --- /dev/null +++ b/core/drizzle/meta/0001_snapshot.json @@ -0,0 +1,683 @@ +{ + "id": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7", + "prevId": "42cf39d2-7833-4bf3-9043-4e5303da8a75", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ekklesia_core_account": { + "name": "ekklesia_core_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_account_user_id_idx": { + "name": "ekklesia_core_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_account_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_account", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_session": { + "name": "ekklesia_core_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_session_user_id_idx": { + "name": "ekklesia_core_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_session_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_session", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_session_token_unique": { + "name": "ekklesia_core_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_user": { + "name": "ekklesia_core_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_user_email_unique": { + "name": "ekklesia_core_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_verification": { + "name": "ekklesia_core_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_verification_identifier_idx": { + "name": "ekklesia_core_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_authorization_audit": { + "name": "ekklesia_core_authorization_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_authorization_audit_subject_idx": { + "name": "ekklesia_core_authorization_audit_subject_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_authorization_audit", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["subject_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_authorization_audit_action_check": { + "name": "ekklesia_core_authorization_audit_action_check", + "value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')" + }, + "ekklesia_core_authorization_audit_actor_type_check": { + "name": "ekklesia_core_authorization_audit_actor_type_check", + "value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')" + }, + "ekklesia_core_authorization_audit_scope_check": { + "name": "ekklesia_core_authorization_audit_scope_check", + "value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_grant": { + "name": "ekklesia_core_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_grant_user_permission_idx": { + "name": "ekklesia_core_grant_user_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_grant", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_grant_source_kind_check": { + "name": "ekklesia_core_grant_source_kind_check", + "value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')" + }, + "ekklesia_core_grant_created_actor_type_check": { + "name": "ekklesia_core_grant_created_actor_type_check", + "value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_grant_revoked_actor_type_check": { + "name": "ekklesia_core_grant_revoked_actor_type_check", + "value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner": { + "name": "ekklesia_core_platform_owner", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_actor_type": { + "name": "granted_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_actor_id": { + "name": "granted_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_platform_owner", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_granted_actor_type_check": { + "name": "ekklesia_core_platform_owner_granted_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_platform_owner_revoked_actor_type_check": { + "name": "ekklesia_core_platform_owner_revoked_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index 07be324..856273c 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -7,6 +7,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0001_authorization_core_tables", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index c6f7b2b..3d8715f 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1784668552522, "tag": "0000_identity_core_tables", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1784819635300, + "tag": "0001_authorization_core_tables", + "breakpoints": true } ] } diff --git a/core/package.json b/core/package.json index a25d3b7..1f46033 100644 --- a/core/package.json +++ b/core/package.json @@ -26,6 +26,14 @@ "./identity": { "types": "./dist/identity/index.d.ts", "import": "./dist/identity/index.js" + }, + "./authorization": { + "types": "./dist/authorization/index.d.ts", + "import": "./dist/authorization/index.js" + }, + "./database": { + "types": "./dist/database/index.d.ts", + "import": "./dist/database/index.js" } }, "scripts": { 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 new file mode 100644 index 0000000..8050614 --- /dev/null +++ b/core/src/authorization/database.ts @@ -0,0 +1,118 @@ +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool, type PoolConfig } from "pg"; +import { authorizationSchema } from "./schema.js"; + +export type AuthorizationDrizzleDatabase = NodePgDatabase; + +export type AuthorizationDatabaseErrorCode = "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID"; + +const maximumPostgresTimeoutMs = 2_147_483_647; + +/** Never carries the offending connection string, host, or underlying driver error. */ +export class AuthorizationDatabaseError extends Error { + readonly code: AuthorizationDatabaseErrorCode; + + constructor(code: AuthorizationDatabaseErrorCode) { + super("Authorization database configuration is invalid."); + this.name = "AuthorizationDatabaseError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +export interface AuthorizationDatabaseOptions { + readonly connectionTimeoutMs?: number; + /** Bounds both `query_timeout` and `statement_timeout`, mirroring + * `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a + * connection, not a hung query already running on one. */ + readonly operationTimeoutMs?: number; +} + +export interface AuthorizationDatabaseHandle { + readonly close: () => Promise; + readonly db: AuthorizationDrizzleDatabase; +} + +function validateConnectionString(connectionString: string): string { + if (connectionString.trim().length === 0) { + throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID"); + } + let protocol: string; + try { + protocol = new URL(connectionString).protocol; + } catch { + throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID"); + } + if (protocol !== "postgres:" && protocol !== "postgresql:") { + throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID"); + } + return connectionString; +} + +/** Mirrors `core/src/identity/database.ts`'s timeout validation exactly: an explicit timeout must + * be a positive safe integer no larger than PostgreSQL's own maximum, or it is rejected as invalid + * configuration rather than silently passed through to `pg`. */ +function validateTimeout(value: unknown): number | undefined { + if (value === undefined) { + return undefined; + } + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value <= 0 || + value > maximumPostgresTimeoutMs + ) { + throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID"); + } + return value; +} + +/** + * Explicitly constructs the Core-owned Drizzle database instance backing the authorization store. + * Building the `pg.Pool` performs no network I/O until a query is issued, and this function is + * only ever invoked by explicit caller code — never during module import. + */ +export function createAuthorizationDrizzleDatabase( + connectionString: string, + options: AuthorizationDatabaseOptions = {}, +): AuthorizationDatabaseHandle { + const validatedConnectionString = validateConnectionString(connectionString); + const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); + const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs); + const poolConfig: PoolConfig = { + connectionString: validatedConnectionString, + ...(validatedConnectionTimeoutMs === undefined + ? {} + : { connectionTimeoutMillis: validatedConnectionTimeoutMs }), + ...(validatedOperationTimeoutMs === undefined + ? {} + : { + query_timeout: validatedOperationTimeoutMs, + statement_timeout: validatedOperationTimeoutMs, + }), + }; + + const pool = new Pool(poolConfig); + // Same rationale as core/src/identity/database.ts: pg's own documentation requires an error + // listener on the pool, or a server-initiated termination of an idle client becomes an + // unhandled Node.js error and can crash the process. + pool.on("error", () => undefined); + const db = drizzle(pool, { schema: authorizationSchema }); + + return Object.freeze({ + close: () => pool.end(), + db, + }); +} + +/** + * A typed Drizzle view over an externally owned pool — for example one shared with the identity + * schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike + * `createAuthorizationDrizzleDatabase`, this function never constructs, registers an error + * listener on, or closes a pool itself: the caller retains that responsibility exactly once, + * regardless of how many typed views it creates over the same pool. + */ +export function createAuthorizationDrizzleView(pool: Pool): AuthorizationDrizzleDatabase { + return drizzle(pool, { schema: authorizationSchema }); +} diff --git a/core/src/authorization/evaluator.ts b/core/src/authorization/evaluator.ts new file mode 100644 index 0000000..8b8f43a --- /dev/null +++ b/core/src/authorization/evaluator.ts @@ -0,0 +1,143 @@ +import { + AUTHORIZATION_ALLOWED, + AUTHORIZATION_DENIED, + type AuthorizationDecision, + type AuthorizationScope, + type EkklesiaPrincipal, + isAuthenticatedPrincipal, + permissionBaselineSchema, + safeParseAuthorizationScope, + safeParsePermissionId, +} from "@ekklesia/contracts"; +import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js"; + +export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED"; + +/** Never carries the underlying port error, database error, cause, stack, or any other internal + * detail — defense in depth on top of whatever sanitization the injected ports themselves already + * perform (`core/src/authorization/store.ts` already sanitizes), exactly like + * `core/src/identity/runtime.ts`'s `resolvePrincipal` re-sanitizes on top of Better Auth's own + * error handling rather than trusting it exclusively. */ +export class AuthorizationEvaluationError extends Error { + readonly code: AuthorizationEvaluationErrorCode; + + constructor() { + super("The authorization decision could not be evaluated."); + this.name = "AuthorizationEvaluationError"; + this.code = "AUTHORIZATION_EVALUATION_FAILED"; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +export interface AuthorizeRequest { + readonly principal: EkklesiaPrincipal; + /** Validated internally via the branded `PermissionId` schema *before any baseline can return an + * allowed decision* — a malformed or unrecognizable value denies rather than throwing, for + * `public` and `authenticated` baselines exactly as much as `restricted`. This is shape + * validation only: the evaluator has no registry and cannot tell a well-formed-but-unregistered + * permission from one that is actually registered. Only a trusted caller consulting a + * `PermissionRegistry` (`core/src/authorization/permissions.ts`) before calling `authorize` can + * make that distinction — see that module and `apps/api/src/authorization-middleware.ts`. */ + readonly permission: unknown; + /** Validated internally against the known `public`/`authenticated`/`restricted` set; anything + * else denies rather than throwing, for the same fail-closed reason. */ + readonly baseline: unknown; + /** Optional; validated internally when present. Absent means an unscoped check. */ + readonly scope?: unknown; +} + +export interface AuthorizationEvaluator { + authorize(request: AuthorizeRequest): Promise; +} + +export interface CreateAuthorizationEvaluatorOptions { + readonly grants: AuthorizationGrantPort; + readonly platformOwners: AuthorizationPlatformOwnerPort; + /** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the + * real wall clock; production composition must leave this unset. */ + readonly now?: () => Date; +} + +/** + * Core's Hono-independent, deny-by-default authorization evaluator. It is the only place + * Ekklesia decides whether a principal may exercise a permission, and it never trusts anything + * the caller supplies beyond `principal`/`permission`/`baseline`/`scope` — there is no field for a + * caller to assert "I am the owner" or "I already have this grant." Owner and grant state are + * always re-derived from the injected ports for every call. + * + * A genuine infrastructure failure from either port (for example a database outage) is + * deliberately NOT swallowed into a `denied` decision here: it propagates to the caller as a + * thrown, sanitized `AuthorizationEvaluationError`, exactly like Core's identity + * `resolvePrincipal` never silently downgrades an outage to "anonymous." Only a genuinely + * malformed or unrecognized permission/baseline/scope — a deterministic, expected input problem, + * not an infrastructure failure — resolves to `denied` without throwing. + */ +export function createAuthorizationEvaluator( + options: CreateAuthorizationEvaluatorOptions, +): AuthorizationEvaluator { + const now = options.now ?? (() => new Date()); + + return Object.freeze({ + authorize: async (request: AuthorizeRequest): Promise => { + const parsedBaseline = permissionBaselineSchema.safeParse(request.baseline); + if (!parsedBaseline.success) { + return AUTHORIZATION_DENIED; + } + + // Shape-validated before any baseline is allowed to return `allowed` — a malformed + // permission id must deny under `public` and `authenticated` exactly as much as under + // `restricted`, even though neither of the first two ever reads `parsedPermission.data`. + // This is a pure, synchronous check: it never touches the grant or platform-owner ports, so + // `public`/`authenticated` still resolve with no database lookup at all. + const parsedPermission = safeParsePermissionId(request.permission); + if (!parsedPermission.success) { + return AUTHORIZATION_DENIED; + } + + if (parsedBaseline.data === "public") { + return AUTHORIZATION_ALLOWED; + } + + if (parsedBaseline.data === "authenticated") { + return isAuthenticatedPrincipal(request.principal) + ? AUTHORIZATION_ALLOWED + : AUTHORIZATION_DENIED; + } + + // parsedBaseline.data === "restricted": requires an authenticated principal AND + // (an active matching grant OR platform-owner authority). Baseline alone never satisfies it. + if (!isAuthenticatedPrincipal(request.principal)) { + return AUTHORIZATION_DENIED; + } + + let scope: AuthorizationScope | undefined; + if (request.scope !== undefined) { + const parsedScope = safeParseAuthorizationScope(request.scope); + if (!parsedScope.success) { + return AUTHORIZATION_DENIED; + } + scope = parsedScope.data; + } + + const userId = request.principal.userId; + let isOwner: boolean; + let hasGrant: boolean; + try { + [isOwner, hasGrant] = await Promise.all([ + options.platformOwners.isPlatformOwner(userId), + options.grants.hasActiveGrant({ + now: now(), + permission: parsedPermission.data, + userId, + ...(scope === undefined ? {} : { scope }), + }), + ]); + } catch { + throw new AuthorizationEvaluationError(); + } + + return isOwner || hasGrant ? AUTHORIZATION_ALLOWED : AUTHORIZATION_DENIED; + }, + }); +} diff --git a/core/src/authorization/index.ts b/core/src/authorization/index.ts new file mode 100644 index 0000000..272b3ad --- /dev/null +++ b/core/src/authorization/index.ts @@ -0,0 +1,6 @@ +export * from "./bootstrap.js"; +export * from "./database.js"; +export * from "./evaluator.js"; +export * from "./permissions.js"; +export * from "./ports.js"; +export * from "./store.js"; 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/ports.ts b/core/src/authorization/ports.ts new file mode 100644 index 0000000..37c06f4 --- /dev/null +++ b/core/src/authorization/ports.ts @@ -0,0 +1,28 @@ +import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; + +export interface ActiveGrantQuery { + readonly userId: EkklesiaUserId; + readonly permission: PermissionId; + /** Absent means an unscoped check. An unscoped grant satisfies either an unscoped or scoped + * check for the same permission; a scoped grant satisfies only the exact matching scope and + * never satisfies an unscoped check. */ + readonly scope?: AuthorizationScope; + /** The instant to evaluate expiration against — injected so evaluation is deterministic and + * testable rather than reading the wall clock inside the lookup itself. */ + readonly now: Date; +} + +/** + * The only read dependency `createAuthorizationEvaluator` has for grant state. Narrow and + * Postgres-independent by design: a test can supply a fake implementation, and the evaluator + * itself never knows whether it is backed by `core/src/authorization/store.ts` or a test double. + */ +export interface AuthorizationGrantPort { + hasActiveGrant(query: ActiveGrantQuery): Promise; +} + +/** The only read dependency the evaluator has for platform-owner authority — deliberately a + * separate, narrower port from grants, since owner authority is not represented as a grant. */ +export interface AuthorizationPlatformOwnerPort { + isPlatformOwner(userId: EkklesiaUserId): Promise; +} diff --git a/core/src/authorization/schema.ts b/core/src/authorization/schema.ts new file mode 100644 index 0000000..87c05ed --- /dev/null +++ b/core/src/authorization/schema.ts @@ -0,0 +1,145 @@ +import { relations, sql } from "drizzle-orm"; +import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; +import { ekklesiaCoreUser } from "../identity/schema.js"; + +/** + * Core-owned authorization schema: grants (explicit or relationship-derived), platform-owner + * status, and an append-only audit log of every security-relevant mutation. Table and index names + * are explicit and namespaced so ownership is unambiguous inside the shared public PostgreSQL + * schema, exactly like `core/src/identity/schema.ts`. Foreign keys to `ekklesia_core_user` are + * safe here (unlike a module referencing another module's tables) because Core owns both sides. + */ + +export const ekklesiaCoreGrant = pgTable( + "ekklesia_core_grant", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }), + permissionId: text("permission_id").notNull(), + scope: text("scope"), + sourceKind: text("source_kind").notNull(), + sourceRef: text("source_ref"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + createdByActorType: text("created_by_actor_type").notNull(), + createdByActorId: text("created_by_actor_id"), + expiresAt: timestamp("expires_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + revokedByActorType: text("revoked_by_actor_type"), + revokedByActorId: text("revoked_by_actor_id"), + }, + (table) => [ + index("ekklesia_core_grant_user_permission_idx").on(table.userId, table.permissionId), + check( + "ekklesia_core_grant_source_kind_check", + sql`${table.sourceKind} in ('explicit', 'relationship')`, + ), + check( + "ekklesia_core_grant_created_actor_type_check", + sql`${table.createdByActorType} in ('user', 'system')`, + ), + check( + "ekklesia_core_grant_revoked_actor_type_check", + sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`, + ), + ], +); + +export const ekklesiaCorePlatformOwner = pgTable( + "ekklesia_core_platform_owner", + { + userId: text("user_id") + .primaryKey() + .references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }), + grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(), + grantedByActorType: text("granted_by_actor_type").notNull(), + grantedByActorId: text("granted_by_actor_id"), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + revokedByActorType: text("revoked_by_actor_type"), + revokedByActorId: text("revoked_by_actor_id"), + }, + (table) => [ + check( + "ekklesia_core_platform_owner_granted_actor_type_check", + sql`${table.grantedByActorType} in ('user', 'system')`, + ), + check( + "ekklesia_core_platform_owner_revoked_actor_type_check", + sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`, + ), + ], +); + +/** + * Append-only: no update or delete path is ever exposed by Core's authorization store. `scope` + * intentionally rejects an empty (but non-null) string — defense in depth matching the + * application-layer `AuthorizationScope` contract, which never produces one — so a coding defect + * that slipped an empty scope past that validation fails the whole grant-plus-audit transaction + * atomically instead of recording a meaningless audit entry. + */ +export const ekklesiaCoreAuthorizationAudit = pgTable( + "ekklesia_core_authorization_audit", + { + id: uuid("id").primaryKey().defaultRandom(), + action: text("action").notNull(), + actorType: text("actor_type").notNull(), + actorId: text("actor_id"), + subjectUserId: text("subject_user_id") + .notNull() + .references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }), + permissionId: text("permission_id"), + scope: text("scope"), + grantId: uuid("grant_id"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("ekklesia_core_authorization_audit_subject_idx").on(table.subjectUserId), + check( + "ekklesia_core_authorization_audit_action_check", + sql`${table.action} in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')`, + ), + check( + "ekklesia_core_authorization_audit_actor_type_check", + sql`${table.actorType} in ('user', 'system')`, + ), + check( + "ekklesia_core_authorization_audit_scope_check", + sql`${table.scope} is null or length(${table.scope}) > 0`, + ), + ], +); + +export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({ + user: one(ekklesiaCoreUser, { + fields: [ekklesiaCoreGrant.userId], + references: [ekklesiaCoreUser.id], + }), +})); + +export const ekklesiaCorePlatformOwnerRelations = relations( + ekklesiaCorePlatformOwner, + ({ one }) => ({ + user: one(ekklesiaCoreUser, { + fields: [ekklesiaCorePlatformOwner.userId], + references: [ekklesiaCoreUser.id], + }), + }), +); + +export const ekklesiaCoreAuthorizationAuditRelations = relations( + ekklesiaCoreAuthorizationAudit, + ({ one }) => ({ + subject: one(ekklesiaCoreUser, { + fields: [ekklesiaCoreAuthorizationAudit.subjectUserId], + references: [ekklesiaCoreUser.id], + }), + }), +); + +/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */ +export const authorizationSchema = { + authorizationAudit: ekklesiaCoreAuthorizationAudit, + grant: ekklesiaCoreGrant, + platformOwner: ekklesiaCorePlatformOwner, +} as const; diff --git a/core/src/authorization/store.ts b/core/src/authorization/store.ts new file mode 100644 index 0000000..0946539 --- /dev/null +++ b/core/src/authorization/store.ts @@ -0,0 +1,389 @@ +import type { + AuthorizationActor, + AuthorizationScope, + EkklesiaUserId, + GrantId, + GrantSourceKind, + GrantSourceRef, + PermissionId, +} from "@ekklesia/contracts"; +import { + isUserActor, + parseAuthorizationScope, + parseEkklesiaUserId, + parseGrantId, + parsePermissionId, +} from "@ekklesia/contracts"; +import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm"; +import type { AuthorizationDrizzleDatabase } from "./database.js"; +import type { + ActiveGrantQuery, + AuthorizationGrantPort, + AuthorizationPlatformOwnerPort, +} from "./ports.js"; +import { + ekklesiaCoreAuthorizationAudit, + ekklesiaCoreGrant, + ekklesiaCorePlatformOwner, +} from "./schema.js"; + +export type AuthorizationStoreErrorCode = + | "AUTHORIZATION_GRANT_CREATE_FAILED" + | "AUTHORIZATION_GRANT_LIST_FAILED" + | "AUTHORIZATION_GRANT_LOOKUP_FAILED" + | "AUTHORIZATION_GRANT_NOT_FOUND" + | "AUTHORIZATION_GRANT_REVOKE_FAILED" + | "AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED" + | "AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED" + | "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND"; + +const storeErrorMessages: Readonly> = Object.freeze({ + AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.", + AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.", + AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.", + AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.", + AUTHORIZATION_GRANT_REVOKE_FAILED: "The authorization grant could not be revoked.", + AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED: "The platform-owner lookup could not be completed.", + AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED: "The platform-owner change could not be recorded.", + AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND: + "The platform-owner record was not found or is already revoked.", +}); + +/** Never carries the underlying database error, SQL text, host, port, path, or stack. */ +export class AuthorizationStoreError extends Error { + readonly code: AuthorizationStoreErrorCode; + + constructor(code: AuthorizationStoreErrorCode) { + super(storeErrorMessages[code]); + this.name = "AuthorizationStoreError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +function isAuthorizationStoreError(error: unknown): error is AuthorizationStoreError { + return error instanceof AuthorizationStoreError; +} + +export interface CreateGrantInput { + readonly userId: EkklesiaUserId; + readonly permission: PermissionId; + readonly scope?: AuthorizationScope; + readonly sourceKind: GrantSourceKind; + readonly sourceRef?: GrantSourceRef; + readonly actor: AuthorizationActor; + /** Absent means the grant never expires. */ + readonly expiresAt?: Date; +} + +export interface GrantRecord { + readonly id: GrantId; + readonly userId: EkklesiaUserId; + readonly permission: PermissionId; + readonly scope?: AuthorizationScope; + readonly sourceKind: GrantSourceKind; + readonly sourceRef?: GrantSourceRef; + readonly createdAt: Date; + readonly expiresAt?: Date; + readonly revokedAt?: Date; +} + +export interface RevokeGrantInput { + readonly grantId: GrantId; + readonly actor: AuthorizationActor; + /** When provided, the revoke only matches a grant whose `sourceKind` equals this value — a + * grant of a different source kind is treated exactly like a nonexistent one + * (`AUTHORIZATION_GRANT_NOT_FOUND`). The generic explicit-grant HTTP API always passes + * `"explicit"` here, so it can never revoke a relationship-derived grant regardless of caller + * intent; internal Core callers that omit this may revoke any grant, matching pass 1's + * original, unconstrained behavior. */ + readonly sourceKind?: GrantSourceKind; +} + +export interface ListGrantsForUserOptions { + /** When provided, only grants with this exact `sourceKind` are returned. */ + readonly sourceKind?: GrantSourceKind; +} + +/** + * Core's Postgres-backed authorization persistence: grants, platform-owner status, and the + * append-only audit log, all behind one explicit factory — no environment reads, no connection + * attempt, and no query merely from importing this module or calling this factory; every method + * call is the only thing that ever touches PostgreSQL. Implements the narrow read ports + * `createAuthorizationEvaluator` depends on directly, so the same store instance is both Core's + * write API and the evaluator's read dependency. + */ +export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort { + createGrant(input: CreateGrantInput): Promise; + 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; +} + +function actorId(actor: AuthorizationActor): string | null { + return isUserActor(actor) ? actor.userId : null; +} + +interface GrantRow { + readonly id: string; + readonly userId: string; + readonly permissionId: string; + readonly scope: string | null; + readonly sourceKind: string; + readonly sourceRef: string | null; + readonly createdAt: Date; + readonly expiresAt: Date | null; + readonly revokedAt: Date | null; +} + +/** Re-validates a row this same store just wrote, rather than trusting raw column values by + * construction — defense in depth against a future schema/migration drift, matching the + * paranoid-by-default posture the rest of Core's persistence code already follows. */ +function toGrantRecord(row: GrantRow): GrantRecord { + return Object.freeze({ + createdAt: row.createdAt, + id: parseGrantId(row.id), + permission: parsePermissionId(row.permissionId), + ...(row.scope === null ? {} : { scope: parseAuthorizationScope(row.scope) }), + ...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt }), + ...(row.revokedAt === null ? {} : { revokedAt: row.revokedAt }), + sourceKind: row.sourceKind as GrantSourceKind, + ...(row.sourceRef === null ? {} : { sourceRef: row.sourceRef }), + userId: parseEkklesiaUserId(row.userId), + }); +} + +export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): AuthorizationStore { + return Object.freeze({ + createGrant: async (input: CreateGrantInput): Promise => { + try { + return await db.transaction(async (tx) => { + const [row] = await tx + .insert(ekklesiaCoreGrant) + .values({ + createdByActorId: actorId(input.actor), + createdByActorType: input.actor.type, + expiresAt: input.expiresAt ?? null, + permissionId: input.permission, + scope: input.scope ?? null, + sourceKind: input.sourceKind, + sourceRef: input.sourceRef ?? null, + userId: input.userId, + }) + .returning(); + if (!row) { + throw new Error("grant insert returned no row"); + } + + await tx.insert(ekklesiaCoreAuthorizationAudit).values({ + action: "grant.create", + actorId: actorId(input.actor), + actorType: input.actor.type, + grantId: row.id, + permissionId: input.permission, + scope: input.scope ?? null, + subjectUserId: input.userId, + }); + + return toGrantRecord(row); + }); + } catch (error) { + if (isAuthorizationStoreError(error)) { + throw error; + } + throw new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED"); + } + }, + + revokeGrant: async (input: RevokeGrantInput): Promise => { + try { + await db.transaction(async (tx) => { + const [row] = await tx + .update(ekklesiaCoreGrant) + .set({ + revokedAt: sql`now()`, + revokedByActorId: actorId(input.actor), + revokedByActorType: input.actor.type, + }) + .where( + and( + eq(ekklesiaCoreGrant.id, input.grantId), + isNull(ekklesiaCoreGrant.revokedAt), + ...(input.sourceKind === undefined + ? [] + : [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]), + ), + ) + .returning(); + if (!row) { + throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND"); + } + + await tx.insert(ekklesiaCoreAuthorizationAudit).values({ + action: "grant.revoke", + actorId: actorId(input.actor), + actorType: input.actor.type, + grantId: row.id, + permissionId: row.permissionId, + scope: row.scope, + subjectUserId: row.userId, + }); + }); + } catch (error) { + if (isAuthorizationStoreError(error)) { + throw error; + } + throw new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED"); + } + }, + + hasActiveGrant: async (query: ActiveGrantQuery): Promise => { + try { + const scopeCondition = + query.scope === undefined + ? isNull(ekklesiaCoreGrant.scope) + : or(isNull(ekklesiaCoreGrant.scope), eq(ekklesiaCoreGrant.scope, query.scope)); + const rows = await db + .select({ id: ekklesiaCoreGrant.id }) + .from(ekklesiaCoreGrant) + .where( + and( + eq(ekklesiaCoreGrant.userId, query.userId), + eq(ekklesiaCoreGrant.permissionId, query.permission), + scopeCondition, + isNull(ekklesiaCoreGrant.revokedAt), + or(isNull(ekklesiaCoreGrant.expiresAt), gt(ekklesiaCoreGrant.expiresAt, query.now)), + ), + ) + .limit(1); + return rows.length > 0; + } catch { + throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LOOKUP_FAILED"); + } + }, + + listGrantsForUser: async ( + userId: EkklesiaUserId, + options: ListGrantsForUserOptions = {}, + ): Promise => { + try { + const rows = await db + .select() + .from(ekklesiaCoreGrant) + .where( + and( + eq(ekklesiaCoreGrant.userId, userId), + ...(options.sourceKind === undefined + ? [] + : [eq(ekklesiaCoreGrant.sourceKind, options.sourceKind)]), + ), + ) + .orderBy(desc(ekklesiaCoreGrant.createdAt)); + return Object.freeze(rows.map(toGrantRecord)); + } catch { + throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED"); + } + }, + + grantPlatformOwner: async ( + userId: EkklesiaUserId, + actor: AuthorizationActor, + ): Promise => { + try { + await db.transaction(async (tx) => { + await tx + .insert(ekklesiaCorePlatformOwner) + .values({ + grantedByActorId: actorId(actor), + grantedByActorType: actor.type, + userId, + }) + .onConflictDoUpdate({ + set: { + grantedAt: sql`now()`, + grantedByActorId: actorId(actor), + grantedByActorType: actor.type, + revokedAt: null, + revokedByActorId: null, + revokedByActorType: null, + }, + target: ekklesiaCorePlatformOwner.userId, + }); + + await tx.insert(ekklesiaCoreAuthorizationAudit).values({ + action: "platform_owner.grant", + actorId: actorId(actor), + actorType: actor.type, + subjectUserId: userId, + }); + }); + } catch { + throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED"); + } + }, + + revokePlatformOwner: async ( + userId: EkklesiaUserId, + actor: AuthorizationActor, + ): Promise => { + try { + await db.transaction(async (tx) => { + const [row] = await tx + .update(ekklesiaCorePlatformOwner) + .set({ + revokedAt: sql`now()`, + revokedByActorId: actorId(actor), + revokedByActorType: actor.type, + }) + .where( + and( + eq(ekklesiaCorePlatformOwner.userId, userId), + isNull(ekklesiaCorePlatformOwner.revokedAt), + ), + ) + .returning(); + if (!row) { + throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND"); + } + + await tx.insert(ekklesiaCoreAuthorizationAudit).values({ + action: "platform_owner.revoke", + actorId: actorId(actor), + actorType: actor.type, + subjectUserId: userId, + }); + }); + } catch (error) { + if (isAuthorizationStoreError(error)) { + throw error; + } + throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED"); + } + }, + + isPlatformOwner: async (userId: EkklesiaUserId): Promise => { + try { + const rows = await db + .select({ userId: ekklesiaCorePlatformOwner.userId }) + .from(ekklesiaCorePlatformOwner) + .where( + and( + eq(ekklesiaCorePlatformOwner.userId, userId), + isNull(ekklesiaCorePlatformOwner.revokedAt), + ), + ) + .limit(1); + return rows.length > 0; + } catch { + throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED"); + } + }, + }); +} 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 8cc005f..1a9ca00 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,3 +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 new file mode 100644 index 0000000..e5dba33 --- /dev/null +++ b/core/tests/authorization-database.test.ts @@ -0,0 +1,164 @@ +import type { Pool } from "pg"; +import { describe, expect, it, vi } from "vitest"; + +const poolConstructor = vi.hoisted(() => vi.fn()); + +vi.mock("pg", () => ({ Pool: poolConstructor })); + +import { + AuthorizationDatabaseError, + createAuthorizationDrizzleDatabase, + createAuthorizationDrizzleView, +} from "../src/authorization/database.js"; + +describe("Core authorization Drizzle database composition", () => { + it("does not construct a pool merely because the module is imported", () => { + expect(poolConstructor).not.toHaveBeenCalled(); + }); + + it.each([ + "", + " ", + "not-a-database-url", + "https://admin:do-not-log@db.example.test/database", + ])("rejects an invalid connection string without exposing it (%s)", (connectionString) => { + let captured: unknown; + try { + createAuthorizationDrizzleDatabase(connectionString); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(AuthorizationDatabaseError); + expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" }); + const publicText = `${String(captured)}\n${ + captured instanceof Error ? (captured.stack ?? "") : "" + }`; + if (connectionString) { + expect(publicText).not.toContain(connectionString); + } + expect(poolConstructor).not.toHaveBeenCalled(); + }); + + it("explicitly constructs the pool only when called, with the authorization schema attached", () => { + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on: vi.fn() }; + }); + + const handle = createAuthorizationDrizzleDatabase( + "postgresql://operator:do-not-log@example.test/ekklesia", + ); + + expect(poolConstructor).toHaveBeenCalledTimes(1); + expect(handle.db).toBeDefined(); + expect(typeof handle.close).toBe("function"); + expect(Object.isFrozen(handle)).toBe(true); + }); + + it("passes the connection timeout through to the pool configuration", () => { + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on: vi.fn() }; + }); + poolConstructor.mockClear(); + + createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", { + connectionTimeoutMs: 5_000, + }); + + expect(poolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ connectionTimeoutMillis: 5_000 }), + ); + }); + + it("passes the operation timeout through to the pool configuration as both query_timeout and statement_timeout", () => { + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on: vi.fn() }; + }); + poolConstructor.mockClear(); + + createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", { + operationTimeoutMs: 7_500, + }); + + expect(poolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ query_timeout: 7_500, statement_timeout: 7_500 }), + ); + }); + + it("does not set query_timeout, statement_timeout, or connectionTimeoutMillis when no timeout is configured", () => { + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on: vi.fn() }; + }); + poolConstructor.mockClear(); + + createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia"); + + const [poolConfig] = poolConstructor.mock.calls[0] as [Record]; + expect(poolConfig).not.toHaveProperty("query_timeout"); + expect(poolConfig).not.toHaveProperty("statement_timeout"); + expect(poolConfig).not.toHaveProperty("connectionTimeoutMillis"); + }); + + it.each([ + ["operationTimeoutMs", 0], + ["operationTimeoutMs", -1], + ["operationTimeoutMs", Number.NaN], + ["operationTimeoutMs", 1.5], + ["operationTimeoutMs", 2_147_483_648], + ["connectionTimeoutMs", 0], + ["connectionTimeoutMs", -1], + ["connectionTimeoutMs", Number.NaN], + ] as const)("rejects an invalid %s (%s) without constructing a pool and without leaking the value", (optionName, value) => { + poolConstructor.mockClear(); + + let captured: unknown; + try { + createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", { + [optionName]: value, + }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(AuthorizationDatabaseError); + expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" }); + const publicText = `${String(captured)}\n${ + captured instanceof Error ? (captured.stack ?? "") : "" + }`; + expect(publicText).not.toContain(String(value)); + expect(poolConstructor).not.toHaveBeenCalled(); + }); + + it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => { + const on = vi.fn(); + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on }; + }); + + createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia"); + + expect(on).toHaveBeenCalledWith("error", expect.any(Function)); + const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as + | ((error: unknown) => void) + | undefined; + expect(() => + handler?.(new Error("terminating connection due to administrator command")), + ).not.toThrow(); + }); +}); + +describe("createAuthorizationDrizzleView", () => { + it("wraps an externally owned pool without constructing, registering an error listener on, or closing one itself", () => { + poolConstructor.mockClear(); + const fakePool = { end: vi.fn(), on: vi.fn(), query: vi.fn() }; + + // A minimal fake pg.Pool double is sufficient — this test proves + // createAuthorizationDrizzleView never touches the pool beyond wrapping it. + const db = createAuthorizationDrizzleView(fakePool as unknown as Pool); + + expect(db).toBeDefined(); + expect(poolConstructor).not.toHaveBeenCalled(); + expect(fakePool.on).not.toHaveBeenCalled(); + expect(fakePool.end).not.toHaveBeenCalled(); + }); +}); diff --git a/core/tests/authorization-evaluator.test.ts b/core/tests/authorization-evaluator.test.ts new file mode 100644 index 0000000..bbc41ca --- /dev/null +++ b/core/tests/authorization-evaluator.test.ts @@ -0,0 +1,544 @@ +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + parseAuthorizationScope, + parseEkklesiaUserId, + parsePermissionId, +} from "@ekklesia/contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + type ActiveGrantQuery, + AuthorizationEvaluationError, + type AuthorizationGrantPort, + type AuthorizationPlatformOwnerPort, + type AuthorizeRequest, + createAuthorizationEvaluator, +} from "../src/authorization/index.js"; + +interface FakeGrant { + readonly userId: string; + readonly permission: string; + readonly scope?: string; + readonly expiresAt?: Date; + readonly revokedAt?: Date; +} + +/** An in-memory double implementing the exact scope/expiry/revocation matching semantics the real + * Postgres store implements in SQL, so the evaluator's own decision logic can be exercised in + * isolation. The live integration suite separately proves the SQL performs the same matching. */ +class FakeGrantStore implements AuthorizationGrantPort { + readonly grants: FakeGrant[] = []; + readonly calls: ActiveGrantQuery[] = []; + + async hasActiveGrant(query: ActiveGrantQuery): Promise { + this.calls.push(query); + return this.grants.some((grant) => { + if (grant.userId !== query.userId || grant.permission !== query.permission) { + return false; + } + if (grant.revokedAt !== undefined) { + return false; + } + if (grant.expiresAt !== undefined && grant.expiresAt <= query.now) { + return false; + } + if (grant.scope === undefined) { + return true; // an unscoped grant satisfies any request for the same permission + } + return grant.scope === query.scope; // a scoped grant never satisfies an unscoped request + }); + } +} + +class FakeOwnerStore implements AuthorizationPlatformOwnerPort { + private readonly owners = new Set(); + readonly calls: string[] = []; + + makeOwner(userId: string): void { + this.owners.add(userId); + } + + async isPlatformOwner(userId: string): Promise { + this.calls.push(userId); + return this.owners.has(userId); + } +} + +const userId = parseEkklesiaUserId("user_1"); +const otherUserId = parseEkklesiaUserId("user_2"); +const permission = parsePermissionId("calendar.events.read"); +const scope = parseAuthorizationScope("calendar-event:123"); +const otherScope = parseAuthorizationScope("calendar-event:456"); + +describe("Core authorization evaluator", () => { + let grants: FakeGrantStore; + let owners: FakeOwnerStore; + + beforeEach(() => { + grants = new FakeGrantStore(); + owners = new FakeOwnerStore(); + }); + + function evaluator(now?: () => Date) { + return createAuthorizationEvaluator({ + grants, + platformOwners: owners, + ...(now ? { now } : {}), + }); + } + + describe("public baseline", () => { + it("allows an anonymous principal", async () => { + const decision = await evaluator().authorize({ + baseline: "public", + permission, + principal: ANONYMOUS_PRINCIPAL, + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("allows an authenticated principal", async () => { + const decision = await evaluator().authorize({ + baseline: "public", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("never queries grants or platform-owner state for a public check", async () => { + await evaluator().authorize({ + baseline: "public", + permission, + principal: ANONYMOUS_PRINCIPAL, + }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); + + it("denies an anonymous principal when the permission id is malformed, without throwing or querying grants or ownership", async () => { + const decision = await evaluator().authorize({ + baseline: "public", + permission: "not a valid permission id", + principal: ANONYMOUS_PRINCIPAL, + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); + + it("denies an authenticated principal when the permission id is malformed", async () => { + const decision = await evaluator().authorize({ + baseline: "public", + permission: "not a valid permission id", + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); + }); + + describe("authenticated baseline", () => { + it("allows an authenticated principal", async () => { + const decision = await evaluator().authorize({ + baseline: "authenticated", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("rejects an anonymous principal", async () => { + const decision = await evaluator().authorize({ + baseline: "authenticated", + permission, + principal: ANONYMOUS_PRINCIPAL, + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("never queries grants or platform-owner state for an authenticated check", async () => { + await evaluator().authorize({ + baseline: "authenticated", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); + + it("denies an authenticated principal when the permission id is malformed, without throwing or querying grants or ownership", async () => { + const decision = await evaluator().authorize({ + baseline: "authenticated", + permission: "not a valid permission id", + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); + + it("still denies an anonymous principal when the permission id is malformed too", async () => { + const decision = await evaluator().authorize({ + baseline: "authenticated", + permission: "not a valid permission id", + principal: ANONYMOUS_PRINCIPAL, + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); + }); + + describe("restricted baseline", () => { + it("denies an anonymous principal without ever consulting grants or ownership", async () => { + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: ANONYMOUS_PRINCIPAL, + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + expect(owners.calls).toHaveLength(0); + }); + + it("denies an authenticated principal with no grant and no ownership", async () => { + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("allows an authenticated principal holding an active explicit grant", async () => { + grants.grants.push({ permission, userId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("allows an authenticated principal holding an active relationship-derived grant", async () => { + // The evaluator only distinguishes "active grant or not" through the port; source kind is a + // store/audit concern, not an evaluation input — proven here by a grant the fake store + // exposes with no source-kind field at all still being honored. + grants.grants.push({ permission, userId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("matches permissions exactly and does not treat a namespace prefix as inherited authority", async () => { + grants.grants.push({ permission: parsePermissionId("calendar.events.read.detail"), userId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("denies a grant that belongs to a different user", async () => { + grants.grants.push({ permission, userId: otherUserId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("lets an unscoped grant satisfy an unscoped request", async () => { + grants.grants.push({ permission, userId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("lets an unscoped grant satisfy a scoped request for the same permission", async () => { + grants.grants.push({ permission, userId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + scope, + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("lets a scoped grant satisfy a request for the exact matching scope", async () => { + grants.grants.push({ permission, scope, userId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + scope, + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("denies a scoped grant against a different scope", async () => { + grants.grants.push({ permission, scope, userId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + scope: otherScope, + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("never lets a scoped grant satisfy an unscoped request", async () => { + grants.grants.push({ permission, scope, userId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("denies an expired grant", async () => { + const fixedNow = new Date("2026-01-01T00:00:00.000Z"); + grants.grants.push({ + expiresAt: new Date("2025-12-31T00:00:00.000Z"), + permission, + userId, + }); + const decision = await evaluator(() => fixedNow).authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("allows a grant that has not yet expired, evaluated deterministically against an injected clock", async () => { + const fixedNow = new Date("2026-01-01T00:00:00.000Z"); + grants.grants.push({ + expiresAt: new Date("2026-01-02T00:00:00.000Z"), + permission, + userId, + }); + const decision = await evaluator(() => fixedNow).authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("denies a revoked grant even though it has not expired", async () => { + grants.grants.push({ + expiresAt: new Date(Date.now() + 86_400_000), + permission, + revokedAt: new Date(), + userId, + }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("denies an unknown or malformed permission without throwing", async () => { + const decision = await evaluator().authorize({ + baseline: "restricted", + permission: "not a valid permission id", + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + expect(grants.calls).toHaveLength(0); + }); + + it("denies a wildcard-shaped permission without throwing", async () => { + const decision = await evaluator().authorize({ + baseline: "restricted", + permission: "media.*", + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("denies a non-string permission (number, object, null, undefined) without throwing", async () => { + for (const malformed of [42, {}, null, undefined, ["calendar.events.read"]]) { + const decision = await evaluator().authorize({ + baseline: "restricted", + permission: malformed, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + } + }); + + it("denies a malformed scope without throwing", async () => { + grants.grants.push({ permission, userId }); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + scope: "", + }); + expect(decision).toEqual({ allowed: false }); + }); + + it("allows a platform owner with no matching grant at all", async () => { + owners.makeOwner(userId); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("allows a platform owner for a scoped restricted permission with no scoped grant", async () => { + owners.makeOwner(userId); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + scope, + }); + expect(decision).toEqual({ allowed: true }); + }); + + it("does not grant ownership authority to a different user", async () => { + owners.makeOwner(otherUserId); + const decision = await evaluator().authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + }); + }); + + describe("an unknown or malformed baseline", () => { + it("denies rather than defaulting to any known baseline", async () => { + for (const malformed of ["superuser", "", 42, null, undefined, "Public", "PUBLIC"]) { + const decision = await evaluator().authorize({ + baseline: malformed, + permission, + principal: createAuthenticatedPrincipal(userId), + }); + expect(decision).toEqual({ allowed: false }); + } + }); + }); + + describe("a client cannot inject owner or grant state", () => { + it("ignores extra caller-supplied fields such as isOwner/hasGrant and re-derives authority from the injected ports", async () => { + // Simulates a caller that smuggles extra fields the AuthorizeRequest type does not + // declare — going through `unknown` rather than `any` proves the object still satisfies + // AuthorizeRequest structurally; the extra fields are simply never read. + const hostileRequest = { + baseline: "restricted", + hasGrant: true, + isOwner: true, + permission, + principal: createAuthenticatedPrincipal(userId), + } as unknown as AuthorizeRequest; + + const decision = await evaluator().authorize(hostileRequest); + + expect(decision).toEqual({ allowed: false }); + // The evaluator still had to actually ask the injected ports — it did not shortcut on the + // hostile flags. + expect(grants.calls).toHaveLength(1); + expect(owners.calls).toHaveLength(1); + }); + }); + + describe("decision contract", () => { + it("returns a frozen, minimal decision with no extra fields", async () => { + const decision = await evaluator().authorize({ + baseline: "public", + permission, + principal: ANONYMOUS_PRINCIPAL, + }); + expect(Object.isFrozen(decision)).toBe(true); + expect(Object.keys(decision)).toEqual(["allowed"]); + }); + }); + + describe("sanitized failures", () => { + it("propagates a genuine grant-lookup infrastructure failure rather than silently denying", async () => { + const failingGrants: AuthorizationGrantPort = { + hasActiveGrant: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")), + }; + const failingEvaluator = createAuthorizationEvaluator({ + grants: failingGrants, + platformOwners: owners, + }); + + await expect( + failingEvaluator.authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }), + ).rejects.toMatchObject({ + code: "AUTHORIZATION_EVALUATION_FAILED", + }); + }); + + it("propagates a genuine platform-owner lookup infrastructure failure rather than silently denying", async () => { + const failingOwners: AuthorizationPlatformOwnerPort = { + isPlatformOwner: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")), + }; + const failingEvaluator = createAuthorizationEvaluator({ + grants, + platformOwners: failingOwners, + }); + + await expect( + failingEvaluator.authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }), + ).rejects.toBeInstanceOf(AuthorizationEvaluationError); + }); + + it("never leaks a raw infrastructure error message through the thrown error", async () => { + const hostileMessage = "password=hunter2 host=db.internal.example port=5432"; + const failingGrants: AuthorizationGrantPort = { + hasActiveGrant: vi.fn().mockRejectedValue(new Error(hostileMessage)), + }; + const failingEvaluator = createAuthorizationEvaluator({ + grants: failingGrants, + platformOwners: owners, + }); + + let captured: unknown; + try { + await failingEvaluator.authorize({ + baseline: "restricted", + permission, + principal: createAuthenticatedPrincipal(userId), + }); + } catch (error) { + captured = error; + } + + const publicText = `${String(captured)}\n${ + captured instanceof Error ? (captured.stack ?? "") : "" + }`; + expect(publicText).not.toContain("hunter2"); + expect(publicText).not.toContain("db.internal.example"); + }); + }); +}); 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/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts new file mode 100644 index 0000000..5e8ced7 --- /dev/null +++ b/core/tests/authorization-store.integration.ts @@ -0,0 +1,539 @@ +import { randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type AuthorizationScope, + createUserActor, + type GrantId, + parseAuthorizationScope, + parseEkklesiaUserId, + parsePermissionId, + SYSTEM_ACTOR, +} from "@ekklesia/contracts"; +import { Client, type QueryResultRow } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { + AuthorizationStoreError, + createAuthorizationDrizzleDatabase, + createAuthorizationStore, +} from "../src/authorization/index.js"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "../src/migrations/index.js"; + +const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; +const coreMigrationsDirectory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "drizzle", +); +const trackedDatabases = new Set(); +let databaseCounter = 0; + +class IntegrationHarnessError extends Error { + constructor(message: string) { + super(message); + this.name = "IntegrationHarnessError"; + this.stack = `${this.name}: ${this.message}`; + } +} + +function harnessError(message: string): IntegrationHarnessError { + return new IntegrationHarnessError(message); +} + +function baseDatabaseUrl(): string { + const value = process.env[integrationDatabaseUrlEnvironment]; + if (typeof value !== "string" || value.trim() === "") { + throw harnessError("The authorization integration database is not configured."); + } + try { + const parsed = new URL(value); + if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { + throw new TypeError("Unsupported protocol."); + } + } catch { + throw harnessError("The authorization integration database configuration is invalid."); + } + return value; +} + +function quoteDatabaseName(databaseName: string): string { + if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) { + throw harnessError("The generated integration database name is invalid."); + } + return `"${databaseName}"`; +} + +function makeDatabaseName(label: string): string { + const safeLabel = label + .toLowerCase() + .replaceAll(/[^a-z0-9]+/gu, "_") + .replaceAll(/^_+|_+$/gu, "") + .slice(0, 14); + databaseCounter += 1; + return `ekk_authz_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`; +} + +function databaseUrl(databaseName: string): string { + const parsed = new URL(baseDatabaseUrl()); + parsed.pathname = `/${databaseName}`; + parsed.searchParams.delete("application_name"); + return parsed.toString(); +} + +async function openAdministrativeClient(connectionString: string): Promise { + const client = new Client({ connectionString }); + client.on("error", () => undefined); + try { + await client.connect(); + return client; + } catch { + try { + await client.end(); + } catch { + // The public harness failure remains static when connection cleanup also fails. + } + throw harnessError("The authorization integration database connection failed."); + } +} + +async function safeQuery( + 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-authz-no-modules-")); + try { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: emptyModulesDirectory, + platformVersion: "0.1.0", + }, + }); + expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ + "core:0000_identity_core_tables", + "core:0001_authorization_core_tables", + ]); + await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); + } finally { + await rm(emptyModulesDirectory, { recursive: true, force: true }); + } +} + +async function seedUser(client: Client, id: string, email: string): Promise { + await safeQuery( + client, + `insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at) + values ($1, $2, $3, false, now(), now())`, + [id, "Integration Test User", email], + ); +} + +afterAll(async () => { + for (const databaseName of [...trackedDatabases]) { + await dropDatabase(databaseName).catch(() => undefined); + } +}); + +describe.sequential("live Core authorization store", () => { + let database: { name: string; url: string }; + let admin: Client; + let store: ReturnType; + let close: () => Promise; + + const userOneId = parseEkklesiaUserId("authz_user_one"); + const userTwoId = parseEkklesiaUserId("authz_user_two"); + const permission = parsePermissionId("calendar.events.read"); + const scope: AuthorizationScope = parseAuthorizationScope("calendar-event:123"); + const actor = createUserActor("authz_user_two"); // an operator/admin user distinct from the subject + + it("applies a fresh migration and creates exactly the three authorization tables with their constraints", async () => { + database = await createDisposableDatabase("fresh"); + await applyCoreMigrations(database.url); + + admin = await openAdministrativeClient(database.url); + const tables = await safeQuery<{ table_name: string }>( + admin, + `select table_name from information_schema.tables + where table_schema = 'public' and table_name like 'ekklesia_core_%' + order by table_name`, + ); + expect(tables.map((row) => row.table_name)).toEqual([ + "ekklesia_core_account", + "ekklesia_core_authorization_audit", + "ekklesia_core_grant", + "ekklesia_core_migrations", + "ekklesia_core_platform_owner", + "ekklesia_core_session", + "ekklesia_core_user", + "ekklesia_core_verification", + ]); + + await seedUser(admin, userOneId, "authz-one@example.test"); + await seedUser(admin, userTwoId, "authz-two@example.test"); + + const handle = createAuthorizationDrizzleDatabase(database.url); + close = handle.close; + store = createAuthorizationStore(handle.db); + }); + + it("creates a grant and finds it as active for the matching user, permission, and scope", async () => { + const grant = await store.createGrant({ + actor, + permission, + scope, + sourceKind: "explicit", + userId: userOneId, + }); + + expect(grant.userId).toBe(userOneId); + expect(grant.permission).toBe(permission); + expect(grant.scope).toBe(scope); + expect(grant.sourceKind).toBe("explicit"); + expect(grant.revokedAt).toBeUndefined(); + + const active = await store.hasActiveGrant({ + now: new Date(), + permission, + scope, + userId: userOneId, + }); + expect(active).toBe(true); + }); + + it("does not find the grant for a different user, a different permission, or a different scope", async () => { + expect( + await store.hasActiveGrant({ now: new Date(), permission, scope, userId: userTwoId }), + ).toBe(false); + expect( + await store.hasActiveGrant({ + now: new Date(), + permission: parsePermissionId("calendar.events.write"), + scope, + userId: userOneId, + }), + ).toBe(false); + expect( + await store.hasActiveGrant({ + now: new Date(), + permission, + scope: parseAuthorizationScope("calendar-event:456"), + userId: userOneId, + }), + ).toBe(false); + }); + + it("never lets the scoped grant satisfy an unscoped request", async () => { + const active = await store.hasActiveGrant({ now: new Date(), permission, userId: userOneId }); + expect(active).toBe(false); + }); + + it("lets an unscoped grant satisfy both unscoped and scoped requests for the same permission", async () => { + const unscopedPermission = parsePermissionId("calendar.events.list"); + await store.createGrant({ + actor, + permission: unscopedPermission, + sourceKind: "relationship", + sourceRef: "roster:volunteer:42", + userId: userOneId, + }); + + expect( + await store.hasActiveGrant({ + now: new Date(), + permission: unscopedPermission, + userId: userOneId, + }), + ).toBe(true); + expect( + await store.hasActiveGrant({ + now: new Date(), + permission: unscopedPermission, + scope: parseAuthorizationScope("anything-at-all"), + userId: userOneId, + }), + ).toBe(true); + }); + + it("records exactly one atomic audit entry for the grant it just created", async () => { + const rows = await safeQuery<{ + action: string; + actor_id: string | null; + actor_type: string; + permission_id: string | null; + scope: string | null; + subject_user_id: string; + }>( + admin, + `select action, actor_id, actor_type, permission_id, scope, subject_user_id + from "ekklesia_core_authorization_audit" + where subject_user_id = $1 and permission_id = $2`, + [userOneId, permission], + ); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + action: "grant.create", + actor_id: userTwoId, + actor_type: "user", + permission_id: permission, + scope, + subject_user_id: userOneId, + }); + }); + + it("denies a grant once it expires, evaluated against a real elapsed instant", async () => { + const expiringPermission = parsePermissionId("calendar.events.export"); + const past = new Date(Date.now() - 1_000); + await store.createGrant({ + actor, + expiresAt: past, + permission: expiringPermission, + sourceKind: "explicit", + userId: userOneId, + }); + + const active = await store.hasActiveGrant({ + now: new Date(), + permission: expiringPermission, + userId: userOneId, + }); + expect(active).toBe(false); + }); + + it("allows a grant that has not yet expired", async () => { + const notYetExpiredPermission = parsePermissionId("calendar.events.import"); + const future = new Date(Date.now() + 86_400_000); + await store.createGrant({ + actor, + expiresAt: future, + permission: notYetExpiredPermission, + sourceKind: "explicit", + userId: userOneId, + }); + + const active = await store.hasActiveGrant({ + now: new Date(), + permission: notYetExpiredPermission, + userId: userOneId, + }); + expect(active).toBe(true); + }); + + const revocablePermission = parsePermissionId("calendar.events.delete"); + let revocableGrantId: GrantId; + + it("revokes an active grant, records the revocation audit entry, and stops it authorizing", async () => { + const grant = await store.createGrant({ + actor, + permission: revocablePermission, + sourceKind: "explicit", + userId: userOneId, + }); + revocableGrantId = grant.id; + + await store.revokeGrant({ actor, grantId: grant.id }); + + const active = await store.hasActiveGrant({ + now: new Date(), + permission: revocablePermission, + userId: userOneId, + }); + expect(active).toBe(false); + + const audit = await safeQuery<{ action: string }>( + admin, + `select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`, + [revocableGrantId], + ); + expect(audit.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]); + }); + + it("refuses to revoke an already-revoked grant a second time", async () => { + let captured: unknown; + try { + await store.revokeGrant({ actor, grantId: revocableGrantId }); + } catch (error) { + captured = error; + } + expect(captured).toBeInstanceOf(AuthorizationStoreError); + expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_NOT_FOUND" }); + }); + + it("rolls back the whole grant-plus-audit transaction when the audit write violates a real constraint", async () => { + const doomedPermission = parsePermissionId("calendar.events.archive"); + // The audit table's scope CHECK constraint rejects a non-null empty string; the grant table + // has no such constraint. Bypassing the application-layer AuthorizationScope validation (which + // never produces an empty string) with a raw cast lets a real Postgres constraint violation + // fire on the audit insert specifically, after the grant insert within the same transaction + // would otherwise have succeeded. + let captured: unknown; + try { + await store.createGrant({ + actor, + permission: doomedPermission, + scope: "" as AuthorizationScope, + sourceKind: "explicit", + userId: userOneId, + }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(AuthorizationStoreError); + expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" }); + + const orphanedGrants = await safeQuery( + admin, + `select id from "ekklesia_core_grant" where permission_id = $1`, + [doomedPermission], + ); + expect(orphanedGrants).toHaveLength(0); + const orphanedAudit = await safeQuery( + admin, + `select id from "ekklesia_core_authorization_audit" where permission_id = $1`, + [doomedPermission], + ); + expect(orphanedAudit).toHaveLength(0); + }); + + it("is not a platform owner before ever being granted ownership", async () => { + expect(await store.isPlatformOwner(userOneId)).toBe(false); + }); + + it("grants, records the audit entry for, and looks up platform-owner authority", async () => { + await store.grantPlatformOwner(userOneId, actor); + + expect(await store.isPlatformOwner(userOneId)).toBe(true); + expect(await store.isPlatformOwner(userTwoId)).toBe(false); + + const audit = await safeQuery<{ action: string; subject_user_id: string }>( + admin, + `select action, subject_user_id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`, + ); + expect(audit).toEqual([{ action: "platform_owner.grant", subject_user_id: userOneId }]); + }); + + it("revokes platform-owner authority, records the audit entry, and stops future lookups from succeeding", async () => { + await store.revokePlatformOwner(userOneId, actor); + + expect(await store.isPlatformOwner(userOneId)).toBe(false); + + const audit = await safeQuery<{ action: string }>( + admin, + `select action from "ekklesia_core_authorization_audit" where action = 'platform_owner.revoke'`, + ); + expect(audit).toHaveLength(1); + }); + + it("refuses to revoke platform-owner authority a user never held (or already lost)", async () => { + let captured: unknown; + try { + await store.revokePlatformOwner(userTwoId, actor); + } catch (error) { + captured = error; + } + expect(captured).toBeInstanceOf(AuthorizationStoreError); + expect(captured).toMatchObject({ code: "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND" }); + }); + + it("re-grants platform-owner authority after a revocation (upsert), reactivating rather than duplicating the row", async () => { + await store.grantPlatformOwner(userOneId, SYSTEM_ACTOR); + expect(await store.isPlatformOwner(userOneId)).toBe(true); + + const rows = await safeQuery( + admin, + `select user_id from "ekklesia_core_platform_owner" where user_id = $1`, + [userOneId], + ); + expect(rows).toHaveLength(1); + }); + + it("shuts down cleanly", async () => { + await close(); + await safeEnd(admin); + await dropDatabase(database.name); + }); +}); + +describe.sequential("live Core authorization store — no automatic migration", () => { + it("creates no authorization tables merely by constructing the database, the store, or an evaluator against an unmigrated database", async () => { + const database = await createDisposableDatabase("unmigrated"); + try { + const { db, close } = createAuthorizationDrizzleDatabase(database.url); + const store = createAuthorizationStore(db); + expect(store).toBeDefined(); + await close(); + + const admin = await openAdministrativeClient(database.url); + try { + const tables = await safeQuery( + admin, + `select table_name from information_schema.tables where table_schema = 'public'`, + ); + expect(tables).toHaveLength(0); + } finally { + await safeEnd(admin); + } + } finally { + await dropDatabase(database.name); + } + }); +}); diff --git a/core/tests/authorization-store.test.ts b/core/tests/authorization-store.test.ts new file mode 100644 index 0000000..865067d --- /dev/null +++ b/core/tests/authorization-store.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; + +const poolConstructor = vi.hoisted(() => vi.fn()); + +vi.mock("pg", () => ({ Pool: poolConstructor })); + +import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js"; +import { AuthorizationStoreError, createAuthorizationStore } from "../src/authorization/store.js"; + +describe("Core authorization store — import and construction safety", () => { + it("does not construct a pool or open a connection merely because the module is imported", () => { + expect(poolConstructor).not.toHaveBeenCalled(); + }); + + it("does not perform any query merely because the store is constructed", () => { + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on: vi.fn(), query: vi.fn() }; + }); + const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia"); + + const store = createAuthorizationStore(db); + + expect(store).toBeDefined(); + expect(typeof store.createGrant).toBe("function"); + expect(typeof store.revokeGrant).toBe("function"); + expect(typeof store.hasActiveGrant).toBe("function"); + expect(typeof store.grantPlatformOwner).toBe("function"); + expect(typeof store.revokePlatformOwner).toBe("function"); + expect(typeof store.isPlatformOwner).toBe("function"); + expect(Object.isFrozen(store)).toBe(true); + }); +}); + +describe("AuthorizationStoreError", () => { + it.each([ + "AUTHORIZATION_GRANT_CREATE_FAILED", + "AUTHORIZATION_GRANT_LOOKUP_FAILED", + "AUTHORIZATION_GRANT_NOT_FOUND", + "AUTHORIZATION_GRANT_REVOKE_FAILED", + "AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED", + "AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED", + "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND", + ] as const)("exposes a stable code and a fixed, generic message for %s", (code) => { + const error = new AuthorizationStoreError(code); + expect(error.code).toBe(code); + expect(error.name).toBe("AuthorizationStoreError"); + expect(Object.isFrozen(error)).toBe(true); + expect(error.message.length).toBeGreaterThan(0); + }); + + it("never carries a cause, raw driver error, SQL, or stack detail beyond the fixed message", () => { + const error = new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED"); + const publicText = `${String(error)}\n${error.stack ?? ""}`; + expect(publicText).toBe( + `AuthorizationStoreError: ${error.message}\nAuthorizationStoreError: ${error.message}`, + ); + }); +}); 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/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md new file mode 100644 index 0000000..5f84daa --- /dev/null +++ b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md @@ -0,0 +1,230 @@ +# 0013: Core authorization, permission registry, and platform-owner bootstrap + +- Status: Accepted +- Date: 2026-07-23 + +## Context + +ADR 0010 and ADR 0011 established Core-owned identity and its HTTP transport but explicitly left +authorization out of scope: every account received identical baseline authenticated status, and +signup order never granted elevation. `docs/architecture/platform-architecture.md` names +permissions as a Core concern (ADR 0001), not a feature module or a surface concern, alongside +identity, database connectivity, and module lifecycle. + +Phase 4C adds that missing piece in two slices on this branch. The first slice +(`feat: add core authorization foundation`) established the schema, evaluator, store, and +contracts. The second (`feat: add authorization bootstrap and HTTP enforcement`) added an explicit +platform-owner bootstrap operation, a permission registry, and HTTP enforcement wired into +`apps/api`. This ADR records the durable architectural decisions both slices established, since +together they fix a shape future changes must not casually violate: what a permission is, who can +grant one, how a platform owner is established, and how any of this reaches HTTP. + +## Decision + +### Core owns authorization independently of Hono, Surfaces, and modules + +Authorization is a Core platform concern, exactly like identity. The schema +(`core/src/authorization/schema.ts`), evaluator (`core/src/authorization/evaluator.ts`), store +(`core/src/authorization/store.ts`), permission registry (`core/src/authorization/permissions.ts`), +and bootstrap operation (`core/src/authorization/bootstrap.ts`) import nothing from `hono`, +`apps/api`, or any module, and are independently testable without an HTTP transport. `apps/api` +consumes Core's authorization surface the same way it already consumes identity: through explicit, +injected composition (`apps/api/src/authorization-middleware.ts`, +`apps/api/src/grants-routes.ts`), never the reverse. Modules cannot import Core at all under +`scripts/check-boundaries.ts`, so this holds structurally, not just by convention. + +### Permission IDs are exact, registered, and deny by default + +A `PermissionId` (`@ekklesia/contracts`) is a validated, namespaced string +(`core.authorization.grants.manage`), but being well-formed is not the same as being *registered*. +`PermissionRegistry` (`core/src/authorization/permissions.ts`) is the trusted, immutable, +composition-time-built source of which permissions actually exist and what baseline each requires. +Matching is always exact string equality — there is no wildcard, prefix, or namespace-inheritance +matching anywhere in the evaluator or the store; a permission `core.authorization.grants.manage` +never implies or is implied by `core.authorization.grants.*` or `core.authorization.*`. An unknown +or malformed permission id is treated identically to a denied decision, never a distinct "this +would work if declared" response — deny-by-default holds even for permissions nobody has declared +yet. + +Registration is enforced everywhere a permission id can originate from outside Core's own trusted +route wiring, not only for the caller's own required permission. `requirePermission` +(`apps/api/src/authorization-middleware.ts`) checks the registry for the permission a *route* +requires; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately checks the registry +for the *target* permission a caller is requesting be granted to someone else, since that value +comes from the request body rather than route wiring. Without the second check, a caller with +`core.authorization.grants.manage` could create a stored grant for a permission id that is merely +well-formed, not registered — a dormant grant that would silently become effective the moment +that identifier was registered later, with no further action or review at registration time. Both +checks reject with the identical sanitized `403 {"code":"AUTHORIZATION_DENIED"}` an ordinary denial +would return, and neither ever calls into the store on rejection. + +### Permissions declare one trusted baseline: public, authenticated, or restricted + +Every registered permission declares exactly one `PermissionBaseline` at registration time. The +evaluator (`core/src/authorization/evaluator.ts`) still accepts a baseline as part of its own +request shape — that is pass 1's evaluator contract, unchanged — but the only caller allowed to +supply it in this repository is Core's own trusted registry lookup +(`requirePermission` in `apps/api/src/authorization-middleware.ts`); no HTTP route reads a baseline +from a request header, query parameter, or body. `public` always allows; `authenticated` requires a +non-anonymous principal and nothing else; `restricted` requires an authenticated principal *and* +either platform-owner authority or an active matching grant — the baseline alone never satisfies a +restricted check. + +### Unknown permissions and client-supplied baselines fail closed + +Two independent fail-closed layers exist on purpose, not one relied on twice, and they check +different things. The registry itself (`PermissionRegistry.get`/`.has`) returns `undefined`/`false` +for anything malformed or unregistered — this is the only layer that knows what is *registered*. +`requirePermission` treats a registry miss as an immediate sanitized `403`, before principal +resolution or evaluation ever runs; `POST /api/v1/grants` performs the equivalent registry check +for the target permission named in its body (see above). Separately, `createAuthorizationEvaluator` +independently re-validates permission *shape* — it holds no registry and cannot recognize +registration at all — denying (without throwing) any malformed or unrecognized +`permission`/`baseline`/`scope` it receives. This shape check runs, and can deny, **before any +baseline is allowed to return an allowed decision** — `public` and `authenticated` are not +exempt, even though neither reads the permission value for any other reason — so a future caller +that reaches the evaluator directly, bypassing the registry entirely (for example a different +transport Core does not yet have), inherits the same fail-closed guarantee for malformed input +rather than depending on a registry check having already run upstream. The evaluator's shape check +and the registry's registration check are deliberately not merged into one: only the registry can +answer "is this permission registered," and the evaluator must remain usable — and safe against +malformed input — by a caller that has no registry to consult at all. + +### Explicit and relationship-derived grants share persistence but have distinct source kinds + +`ekklesia_core_grant` is one table for both explicit grants (an administrator naming a subject +user, permission, and optional scope directly) and relationship-derived grants (a future module +operation establishing access automatically from a relationship it owns, for example a roster +assignment). A `source_kind` column, constrained to exactly `'explicit'` or `'relationship'`, +distinguishes them at the row level; nothing else about how a grant is stored or evaluated differs +by source kind. This lets both kinds share one evaluator code path and one audit trail while still +letting HTTP enforcement treat them very differently (see below). + +### Unscoped grants satisfy scoped or unscoped checks; scoped grants satisfy only the exact matching scope + +`AuthorizationStore.hasActiveGrant` (`core/src/authorization/store.ts`) matches a grant whose +`scope` column is `NULL` against *any* check, scoped or unscoped, but matches a grant with a +non-null `scope` only against a check requesting that exact scope string. An unscoped grant is +strictly broader than any scoped grant of the same permission; a scoped grant never leaks into an +unscoped check for the same permission. There is no partial, prefix, or hierarchical scope +matching. + +### Revoked and expired grants never authorize + +`hasActiveGrant`'s query requires `revoked_at IS NULL` and either no `expires_at` or +`expires_at` in the future, evaluated against an injectable clock (`now`) rather than each caller's +own wall-clock read. Revocation is soft: `AuthorizationStore.revokeGrant` sets `revoked_at` and the +revoking actor rather than deleting the row, so the audit trail and the grant's own history remain +queryable after revocation, but a revoked row can never again satisfy an authorization check. + +### Platform-owner authority is represented separately from grants and is not a wildcard permission + +`ekklesia_core_platform_owner` is its own table, not a synthetic all-permissions grant row. +`createAuthorizationEvaluator` checks `platformOwners.isPlatformOwner(userId)` as an independent +`OR` alongside `hasActiveGrant`, so owner status bypasses the grant/scope machinery entirely rather +than being implemented as a grant with a special scope or permission value that other code could +accidentally match against. This keeps "is a platform owner" and "has this specific permission" +structurally distinct, so a future owner-management feature can reason about the one table without +touching grant semantics. + +### Platform-owner bootstrap is explicit, CLI-only, globally single-use, permanently consumed even after revocation, concurrency-safe, and audited atomically + +There is no signup-order or first-user inference anywhere in this repository. The only way to +establish a platform owner is `pnpm authorization:bootstrap-owner -- --user-id +--confirm-user-id ` (`core/src/cli/bootstrap-platform-owner.ts`, +`core/src/authorization/bootstrap.ts`), never an HTTP endpoint. "Bootstrap has already been +consumed" is defined as *any row ever created* in `ekklesia_core_platform_owner`, for any user, +including one whose `revoked_at` is set — there is no path in this slice that re-opens bootstrap +after a revocation; a later, separate owner-management feature (not part of this slice) is how a +platform owner would ever be re-established. The single-use check, the target user's existence +check, the owner-row insert, and its audit-row insert are one PostgreSQL transaction, serialized by +a transaction-scoped advisory lock (`pg_advisory_xact_lock`, a key pair distinct from the migration +runner's own lock) acquired as the transaction's first statement, so two concurrent bootstrap +attempts can never both observe "no owner yet" — exactly one succeeds, proven against real +PostgreSQL. Any failure path (nonexistent user, lost race, audit failure) rolls back the entire +transaction, leaving no partial owner or bootstrap state. + +### Identity and Authorization use typed Drizzle views over one physical PostgreSQL pool in apps/api + +`apps/api/src/database.ts`'s `createApiDatabase` constructs exactly one validated `pg.Pool` +(`core/src/database/pool.ts`) and wraps it with two independent typed Drizzle views +(`createIdentityDrizzleView`, `createAuthorizationDrizzleView`) rather than owning two pools. This +is additive: `createIdentityDrizzleDatabase` and `createAuthorizationDrizzleDatabase` (each owning +a private pool) remain available and unaffected for callers — the bootstrap CLI included — that +only need one domain and have no reason to share a connection. `apps/api` closes the one shared +pool exactly once, on graceful shutdown and on any startup failure after the pool was already +constructed. + +### Cookie-authenticated authorization mutations require an exact trusted Origin + +`requireTrustedOrigin` (`apps/api/src/authorization-middleware.ts`) requires an exact match against +the same trusted-origin set already used for CORS and Better Auth's own checks (ADR 0011, ADR +0012) on every grant-mutation route. This is deliberately a server-side check distinct from CORS: +CORS response headers only stop a browser from *reading* a cross-origin response, not from +*sending* the state-changing request in the first place. A missing or untrusted origin is rejected +before the permission check or any state change. Non-browser, non-cookie automation (service +accounts/API tokens) has no way to satisfy this check and is explicitly deferred, not silently +exempted. + +### Generic HTTP grant management handles only explicit grants + +`POST /api/v1/grants` always sets `source_kind` to `'explicit'` server-side — the request body has +no field that can influence it — and `POST /api/v1/grants/:grantId/revoke` always filters its +mutation to `source_kind = 'explicit'`. A relationship-derived grant's id, presented to the revoke +route, matches zero rows and returns the same `404` a nonexistent grant id would, indistinguishably +from the caller's perspective. The generic HTTP API also never accepts `actor`, `sourceKind`, +`sourceRef`, any audit field, a grant id, `createdAt`, or `revokedAt` in a mutation body: the +allowlist rejects the entire request if any other key is present, so a well-formed-looking request +cannot smuggle a server-derived field past validation. + +### Relationship-derived grants may be created or revoked only by the trusted module operation owning that relationship + +No code path in this repository creates a `source_kind = 'relationship'` grant yet — there is no +roster or similar module operation in this slice — but the architectural boundary is fixed here: +when that operation exists, it is the only trusted caller for that grant's lifecycle, exercised +through `AuthorizationStore` directly (as pass 2's own integration tests do to seed one), never +through the generic `/api/v1/grants` HTTP surface. Nothing about this ADR assumes what that future +module operation looks like — only that it does not go through the explicit-grant API. + +### Deferred + +Roles, groups, teams, and tenants; an owner-management HTTP endpoint (revoking or re-granting +platform-owner status over HTTP); service accounts and API tokens for non-browser automation; and +module permission discovery/registration (a mechanism for a module to contribute its own +declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and +remain later work. + +## Alternatives + +- Representing platform-owner status as a special grant row (a synthetic `*` permission, or a + reserved scope value meaning "everything") was rejected: it would make "is this row an owner + grant or a real permission grant" a runtime distinction every future grant-handling code path + would need to remember to check, rather than a structural one enforced by table shape. +- Implementing bootstrap as "check for an existing owner, then call the same `grantPlatformOwner` + function grants already use" was rejected: composing two separately-transacted operations leaves + a window between the check and the grant where a concurrent caller could observe the same "no + owner yet" state, which is exactly the race a single-use bootstrap must not allow. A dedicated, + single-transaction operation closes that window structurally instead of relying on discipline at + every call site. +- Allowing the generic explicit-grant HTTP API to also manage relationship-derived grants (with an + optional `sourceKind` field in the request body) was rejected: it would let any caller holding + `core.authorization.grants.manage` silently unwind access a module relationship established for + reasons the generic API has no way to evaluate — for example, revoking a roster-derived grant + without the roster module ever knowing its own invariant was violated. +- A second physical PostgreSQL pool dedicated to authorization in `apps/api` (mirroring identity's + own private-pool factory) was rejected in favor of one shared pool: `apps/api` is one process + with one connection budget, and two independently-sized pools would double configured connection + limits for no isolation benefit within a single process. + +## Consequences + +Every HTTP route that needs authorization now goes through one reusable middleware +(`requirePermission`) backed by one trusted registry, rather than each route independently deciding +how to check a permission. Establishing a platform owner is a deliberate, auditable, one-time +operator action with no ambiguity about who holds it or how it was granted. Future work extending +authorization — module-declared permissions, relationship-derived grants, roles — has a fixed shape +to extend rather than a green field: new permissions register through the same +`createPermissionRegistry` constructor, new grant sources use the existing `source_kind` column +and audit table, and any new HTTP surface reuses `requirePermission`/`requireTrustedOrigin` rather +than reimplementing enforcement. No identity behavior, route, or response shape from ADR 0010, +0011, or 0012 changed. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index e257026..49a90cd 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -21,3 +21,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted - [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted - [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted +- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted 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..177eda3 --- /dev/null +++ b/docs/development/authorization.md @@ -0,0 +1,336 @@ +# Authorization + +Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into +`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the +platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it. +See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the +HTTP transport it is mounted into, and [ADR +0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable +architectural decisions this page describes in more implementation detail. + +## What this is, and is not + +After this slice, an operator can bootstrap exactly one platform owner for a deployment, a +platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization +grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of +silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants; +there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not +exposed at all in this slice); there are no service accounts or API tokens, so only +cookie-authenticated browser callers can reach the mutation routes; and relationship-derived +grants (for example, automatic access from a roster assignment) cannot yet be created by anything +in this repository — the generic grants API structurally cannot create or revoke them either. + +## Core authorization schema and store + +Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner`, +`ekklesia_core_grant`, `ekklesia_core_authorization_audit`; `AuthorizationEvaluator`, +`AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope` +branded types). This page does not restate that design — read `core/src/authorization/schema.ts`, +`evaluator.ts`, and `store.ts` directly for it. This page covers what pass 2 added on top: the +permission registry, the bootstrap operation, and HTTP enforcement. + +## Permission registry + +`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable, +composition-time-built `PermissionRegistry` distinguishing a *registered declaration* +(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties +matter: + +- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws + `PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id + is declared twice — this can only happen while wiring the registry itself, not at request time. +- **Unknown permissions fail closed at lookup time.** `registry.get(permissionId)` and + `registry.has(permissionId)` both re-validate their input (`safeParsePermissionId` internally) + and return `undefined`/`false` for anything malformed or simply not registered — there is no + "trust this permission id because it looks right" path. + +`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission: + +| Permission | Baseline | Description | +| --- | --- | --- | +| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. | + +Module-owned permission declarations and a discovery/composition path for them are out of scope +for this slice (deferred to the Module SDK work). + +### Two layers, not one: shape versus registration + +`createAuthorizationEvaluator` (`core/src/authorization/evaluator.ts`) has no registry and is +never given one — it independently re-validates `permission`, `baseline`, and `scope` as a pure +shape check (`safeParsePermissionId`/`permissionBaselineSchema`/`safeParseAuthorizationScope`) and +denies a malformed value under **every** baseline, including `public` and `authenticated`, before +any baseline is allowed to return an allowed decision. This is defense in depth for a future +caller that reaches the evaluator directly, not a substitute for the registry: the evaluator has +no way to tell a well-formed-but-unregistered permission id (for example a plausible-looking +`calendar.events.read` that nothing has ever declared) from a genuinely registered one — it can +only reject what is not even shaped like a `PermissionId` at all. + +*Registration* — "does this permission actually exist" — is exclusively the registry's +responsibility, consulted by every trusted caller before the evaluator ever runs: +`requirePermission` (`apps/api/src/authorization-middleware.ts`) looks up the *caller's own* +required permission (for example `core.authorization.grants.manage`, always a hardcoded route +constant, never client-supplied) in the registry before resolving a principal or evaluating +anything; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately looks up the *target* +permission being granted — the one named in the request body — in the same registry, after body +shape-validation and before ever calling `store.createGrant`, so a caller cannot create a dormant +grant for an invented permission id that might later become effective the moment someone actually +registers it. A malformed target permission (fails `PermissionId` shape validation) still resolves +to the body-level `400 {"code":"INVALID_REQUEST"}` documented below, unchanged; a well-formed but +unregistered target permission instead receives the identical sanitized +`403 {"code":"AUTHORIZATION_DENIED"}` a genuinely denied caller would, so that response never +reveals whether the caller was denied or the permission was simply never registered. + +## Platform-owner bootstrap + +There is no "first user becomes owner" inference anywhere in this repository, and no HTTP +endpoint exposes bootstrap. The only way to establish a platform owner is the local CLI command: + +```bash +pnpm migrations:apply # the database must already be migrated +DATABASE_URL=postgresql://... pnpm authorization:bootstrap-owner -- \ + --user-id \ + --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` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. | +| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. | +| `expiresAt` | No | An ISO date string if present. | + +The server always derives, and the request body can never supply: the grant id, `sourceKind` +(always `"explicit"`), the actor (always the authenticated caller, via +`createUserActor(principal.userId)`), and all audit/timestamp fields. + +### Response shapes + +A grant, wherever returned: + +```json +{ + "id": "...", + "userId": "...", + "permission": "core.authorization.grants.manage", + "scope": null, + "sourceKind": "explicit", + "createdAt": "2026-07-23T00:00:00.000Z", + "expiresAt": null, + "revokedAt": null +} +``` + +`GET /api/v1/grants` → `200 {"grants": [...]}`. `POST /api/v1/grants` → `201 {"grant": {...}}`. +`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`. + +### Status-code mapping + +| Status | When | +| --- | --- | +| `200` / `201` | Success. | +| `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). | +| `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`; the caller's own required permission id was unregistered; or, for `POST /api/v1/grants`, the well-formed *target* permission named in the body is not registered. | +| `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. | +| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. | +| `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). | + +Every response sets `Cache-Control: no-store`. + +## Shared database composition + +`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api` +touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded +`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two +independent typed Drizzle views — `createIdentityDrizzleView(pool)` and +`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned +handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool +itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the +identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from +`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown, +and on any startup failure after the pool was already constructed (mirroring the existing +identity-only failure-cleanup path from [API](api.md#graceful-shutdown)). + +The existing per-domain factories (`createIdentityDrizzleDatabase`, +`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are +unaffected and remain available for standalone/testable use — the CLI bootstrap command uses +`createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs +the authorization schema and has no reason to share a pool with anything else. + +`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one +`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization +usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers +there are or whether the failure happened during startup. + +## Tests + +- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration + rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY` contents. +- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing + per-domain database tests). +- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError` + behavior. +- `core/tests/cli-bootstrap-platform-owner.test.ts` — the CLI never constructs a pool merely by + being imported. +- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and + its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner + still blocking reuse, and the concurrency scenario described above. +- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure- + failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior. +- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field + and malformed-field rejection, a well-formed but unregistered target permission rejected with + `403` and `store.createGrant` never called, exact server-derived-argument assertions, + sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and + store-error mapping. +- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above. +- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under + every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never + consulting the grant or platform-owner ports for `public`/`authenticated`. +- `apps/api/tests/api-http.integration.ts` (live PostgreSQL) — end-to-end: anonymous/non-owner + denial, real bootstrap against the running API's own database, owner authority over the HTTP + grants API, the owner delegating `core.authorization.grants.manage` to another account and that + account then managing grants as a non-owner, a well-formed but unregistered target permission + rejected with `403` (with no grant or audit row written) even for a caller with full + grant-management authority, a create/list/revoke cycle with audit-trail verification (using + `core.authorization.grants.manage` itself as the granted permission, since module permission + registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection, + a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an + infrastructure-failure scenario (database dropped out from under a live session) asserting a + `503` rather than a false `403` or `200`. + +Run the focused suites: + +```bash +pnpm --filter @ekklesia/core test +pnpm --filter @ekklesia/api test +pnpm test:migrations:integration # requires Docker +``` + +## Next phase + +Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission +registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service +accounts/API tokens for non-browser automation, and relationship-derived grant creation itself +(a roster or similar module operation, not the generic grants API). diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 6438197..1d1e266 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -106,8 +106,9 @@ Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 1 migration · checksums valid + 2 migrations · checksums valid core:0000_identity_core_tables · f8f2086ef06f + core:0001_authorization_core_tables · 830e1fbc26d0 PostgreSQL ○ Connection check skipped 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/packages/contracts/src/authorization.ts b/packages/contracts/src/authorization.ts new file mode 100644 index 0000000..85aa1e3 --- /dev/null +++ b/packages/contracts/src/authorization.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; +import type { EkklesiaUserId } from "./identity.js"; +import { parseEkklesiaUserId } from "./identity.js"; + +/** + * Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId` and + * `PermissionId`: a plain `string` is not assignable to `GrantId` without passing through + * `parseGrantId`/`safeParseGrantId`. Ekklesia does not assume any particular format; Core's + * Postgres-backed store happens to generate a UUID, but callers must treat the value as opaque. + */ +export const grantIdSchema = z.string().trim().min(1).max(128).brand<"GrantId">(); + +export type GrantId = z.infer; + +export function parseGrantId(input: unknown): GrantId { + return grantIdSchema.parse(input); +} + +export function safeParseGrantId(input: unknown): z.ZodSafeParseResult { + return grantIdSchema.safeParse(input); +} + +/** + * A generic, Core-opaque resource scope narrowing a grant or an authorization check to one + * resource (for example a specific calendar event or media item). Core never interprets the + * value beyond exact string equality — the owning module defines what a scope string means, if + * anything. Not branded: unlike a permission or a grant, a scope carries no identity of its own + * within Core; it is simply an opaque comparison key. + */ +export const authorizationScopeSchema = z.string().trim().min(1).max(256); + +export type AuthorizationScope = z.infer; + +export function parseAuthorizationScope(input: unknown): AuthorizationScope { + return authorizationScopeSchema.parse(input); +} + +export function safeParseAuthorizationScope( + input: unknown, +): z.ZodSafeParseResult { + return authorizationScopeSchema.safeParse(input); +} + +/** + * Every permission declaration has exactly one baseline. `public` allows any principal, anonymous + * or authenticated. `authenticated` requires a valid authenticated principal and nothing more. + * `restricted` requires an active matching grant or platform-owner authority — baseline alone + * never satisfies it. + */ +export const permissionBaselineSchema = z.enum(["public", "authenticated", "restricted"]); + +export type PermissionBaseline = z.infer; + +/** + * Distinguishes a grant an operator/system created directly (`explicit`) from one a module + * created as a side effect of its own business operation, attributed to some source the module + * understands (`relationship`) — for example adding a person to a volunteer roster. Core never + * interprets which relationship produced the grant; it only records the distinction and an + * opaque source reference for audit purposes. + */ +export const grantSourceKindSchema = z.enum(["explicit", "relationship"]); + +export type GrantSourceKind = z.infer; + +/** A Core-opaque reference to whatever produced a `relationship` grant (or, optionally, extra + * context for an `explicit` one) — for example a module-defined identifier. Never interpreted by + * Core, never a foreign key into another module's tables. */ +export const grantSourceRefSchema = z.string().trim().min(1).max(256); + +export type GrantSourceRef = z.infer; + +const authorizationActorTypeSchema = z.enum(["user", "system"]); + +export type AuthorizationActorType = z.infer; + +export interface AuthorizationUserActor { + readonly type: "user"; + readonly userId: EkklesiaUserId; +} + +export interface AuthorizationSystemActor { + readonly type: "system"; +} + +/** + * Who performed a security-relevant authorization mutation (a grant or platform-owner change). + * Mirrors `EkklesiaPrincipal`'s discriminated-union style. `system` covers Core-internal or + * automated actions with no specific human actor (for example a future bootstrap command run by + * an operator with no prior Ekklesia identity); it never means "unauthenticated" or "anonymous + * caller" — every mutating call site chooses this deliberately. + */ +export type AuthorizationActor = AuthorizationUserActor | AuthorizationSystemActor; + +export const SYSTEM_ACTOR: AuthorizationSystemActor = Object.freeze({ type: "system" }); + +export function createUserActor(userId: unknown): AuthorizationUserActor { + return Object.freeze({ type: "user", userId: parseEkklesiaUserId(userId) }); +} + +export function isUserActor(actor: AuthorizationActor): actor is AuthorizationUserActor { + return actor.type === "user"; +} + +/** + * The only public result of an authorization check. Deliberately minimal and frozen: it never + * carries a reason, a matched grant, a permission echo, or any other detail a caller could use to + * infer another user's grants or Core's internal evaluation path. `AUTHORIZATION_ALLOWED` and + * `AUTHORIZATION_DENIED` are the only two values ever produced. + */ +export interface AuthorizationDecision { + readonly allowed: boolean; +} + +export const AUTHORIZATION_ALLOWED: AuthorizationDecision = Object.freeze({ allowed: true }); +export const AUTHORIZATION_DENIED: AuthorizationDecision = Object.freeze({ allowed: false }); + +export function isAuthorizationAllowed(decision: AuthorizationDecision): boolean { + return decision.allowed; +} diff --git a/packages/contracts/src/identifiers.ts b/packages/contracts/src/identifiers.ts index 322faee..0809809 100644 --- a/packages/contracts/src/identifiers.ts +++ b/packages/contracts/src/identifiers.ts @@ -16,11 +16,23 @@ export const capabilityIdSchema = z .max(160) .regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier."); +/** + * Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId`: a plain + * `string` is not assignable to `PermissionId` without passing through + * `parsePermissionId`/`safeParsePermissionId`. The brand adds no runtime tag or wrapper — a value + * still serializes as a plain string. This is the one permission-identifier type shared by module + * manifest declarations (`moduleManifestSchema`'s `permissions` array) and Core's authorization + * evaluator, so a permission a module declares and a permission Core evaluates are provably the + * same identifier space. Namespacing (the required `.`-segments) is purely a naming convention for + * readability and collision-avoidance — it never implies hierarchy or inheritance during + * evaluation, and wildcard segments are not part of this format at all. + */ export const permissionIdSchema = z .string() .min(3) .max(160) - .regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier."); + .regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.") + .brand<"PermissionId">(); export const logicalBucketIdSchema = z .string() @@ -58,3 +70,11 @@ export type CapabilityId = z.infer; export type PermissionId = z.infer; export type LogicalBucketId = z.infer; export type PackageRelativePath = z.infer; + +export function parsePermissionId(input: unknown): PermissionId { + return permissionIdSchema.parse(input); +} + +export function safeParsePermissionId(input: unknown): z.ZodSafeParseResult { + return permissionIdSchema.safeParse(input); +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5cf678a..3fd62c0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,3 +1,4 @@ +export * from "./authorization.js"; export * from "./identifiers.js"; export * from "./identity.js"; export * from "./module-lifecycle.js"; diff --git a/packages/contracts/tests/authorization.test.ts b/packages/contracts/tests/authorization.test.ts new file mode 100644 index 0000000..cd605e8 --- /dev/null +++ b/packages/contracts/tests/authorization.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { + AUTHORIZATION_ALLOWED, + AUTHORIZATION_DENIED, + type AuthorizationActor, + type AuthorizationDecision, + createUserActor, + type GrantId, + isAuthorizationAllowed, + isUserActor, + parseAuthorizationScope, + parseGrantId, + parsePermissionId, + type PermissionId, + safeParseAuthorizationScope, + safeParseGrantId, + safeParsePermissionId, + SYSTEM_ACTOR, +} from "../src/index.js"; + +describe("PermissionId", () => { + it("parses a valid lowercase dot-namespaced permission id", () => { + expect(parsePermissionId("calendar.events.read")).toBe("calendar.events.read"); + }); + + it("is opaque at the type level: a plain string is not assignable without parsing", () => { + // @ts-expect-error a bare string must not satisfy the branded PermissionId type. + const notAllowed: PermissionId = "calendar.events.read"; + expect(notAllowed).toBe("calendar.events.read"); + }); + + it("rejects a non-namespaced, empty, oversized, or non-string permission id", () => { + expect(() => parsePermissionId("read")).toThrow(); + expect(() => parsePermissionId("")).toThrow(); + expect(() => parsePermissionId("Calendar.Events.Read")).toThrow(); + expect(() => parsePermissionId("x".repeat(161))).toThrow(); + expect(() => parsePermissionId(42)).toThrow(); + expect(() => parsePermissionId(null)).toThrow(); + }); + + it("rejects a wildcard segment: there is no such thing as a wildcard permission", () => { + expect(() => parsePermissionId("media.*")).toThrow(); + expect(() => parsePermissionId("media.*.read")).toThrow(); + expect(() => parsePermissionId("*")).toThrow(); + }); + + it("exposes a safe-parse variant that never throws", () => { + expect(safeParsePermissionId("calendar.events.read").success).toBe(true); + expect(safeParsePermissionId("*").success).toBe(false); + }); +}); + +describe("GrantId", () => { + it("parses a non-empty opaque grant id and rejects empty/oversized input", () => { + const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111"); + expect(id).toBe("11111111-1111-1111-1111-111111111111"); + expect(() => parseGrantId("")).toThrow(); + expect(() => parseGrantId("x".repeat(129))).toThrow(); + }); + + it("exposes a safe-parse variant that never throws", () => { + expect(safeParseGrantId("abc").success).toBe(true); + expect(safeParseGrantId("").success).toBe(false); + }); +}); + +describe("AuthorizationScope", () => { + it("accepts a non-empty opaque scope and rejects empty/whitespace/oversized scope", () => { + expect(parseAuthorizationScope("calendar-event:123")).toBe("calendar-event:123"); + expect(safeParseAuthorizationScope("").success).toBe(false); + expect(safeParseAuthorizationScope(" ").success).toBe(false); + expect(safeParseAuthorizationScope("x".repeat(257)).success).toBe(false); + }); +}); + +describe("AuthorizationActor", () => { + it("creates a frozen user actor carrying only the opaque user id", () => { + const actor = createUserActor("user_123"); + expect(actor).toEqual({ type: "user", userId: "user_123" }); + expect(Object.isFrozen(actor)).toBe(true); + expect(isUserActor(actor)).toBe(true); + }); + + it("rejects an invalid user id when creating a user actor", () => { + expect(() => createUserActor("")).toThrow(); + expect(() => createUserActor(42)).toThrow(); + }); + + it("exposes a frozen system actor singleton distinguishable from a user actor", () => { + expect(SYSTEM_ACTOR).toEqual({ type: "system" }); + expect(Object.isFrozen(SYSTEM_ACTOR)).toBe(true); + expect(isUserActor(SYSTEM_ACTOR)).toBe(false); + }); + + it("discriminates actors on type without leaking extra fields", () => { + const actors: AuthorizationActor[] = [SYSTEM_ACTOR, createUserActor("user_123")]; + for (const actor of actors) { + expect(["user", "system"]).toContain(actor.type); + } + expect(Object.keys(SYSTEM_ACTOR)).toEqual(["type"]); + }); +}); + +describe("AuthorizationDecision", () => { + it("exposes exactly two frozen, minimal decision singletons", () => { + expect(AUTHORIZATION_ALLOWED).toEqual({ allowed: true }); + expect(AUTHORIZATION_DENIED).toEqual({ allowed: false }); + expect(Object.isFrozen(AUTHORIZATION_ALLOWED)).toBe(true); + expect(Object.isFrozen(AUTHORIZATION_DENIED)).toBe(true); + expect(Object.keys(AUTHORIZATION_ALLOWED)).toEqual(["allowed"]); + expect(Object.keys(AUTHORIZATION_DENIED)).toEqual(["allowed"]); + }); + + it("never carries a reason, matched grant, permission echo, or any other detail", () => { + const decisions: AuthorizationDecision[] = [AUTHORIZATION_ALLOWED, AUTHORIZATION_DENIED]; + for (const decision of decisions) { + expect(Object.keys(decision)).not.toContain("reason"); + expect(Object.keys(decision)).not.toContain("grantId"); + expect(Object.keys(decision)).not.toContain("permission"); + expect(Object.keys(decision)).not.toContain("code"); + } + }); + + it("exposes a boolean helper matching the allowed field", () => { + expect(isAuthorizationAllowed(AUTHORIZATION_ALLOWED)).toBe(true); + expect(isAuthorizationAllowed(AUTHORIZATION_DENIED)).toBe(false); + }); +}); diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index b97bc8d..49e7c36 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ hookTimeout: 30_000, 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,