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 the injected, * trusted, server-side `PermissionRegistry` — in production, the complete platform registry * `startApi` composes from Core's own declarations plus every installed module's, never a * Core-only fallback. 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; }; }