feat: add authorization bootstrap and HTTP enforcement
Wires Core's identity and authorization domains into apps/api over one shared, validated PostgreSQL pool; adds an explicit, single-use, concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible immutable permission registry with one Core-owned restricted permission (core.authorization.grants.manage); and adds reusable Hono authorization enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
|
||||
import type { AuthorizationEvaluator, PermissionRegistry } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||
|
||||
const PRINCIPAL_CONTEXT_KEY = "ekklesiaPrincipal";
|
||||
|
||||
export interface AuthorizationMiddlewareOptions {
|
||||
readonly evaluator: AuthorizationEvaluator;
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
}
|
||||
|
||||
function sanitizedDenial(c: Context, status: 403 | 503, code: string): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Hono middleware that enforces `permissionId`, resolved exclusively from Core's own
|
||||
* trusted, server-side permission registry — a route never supplies its own baseline, and no
|
||||
* request input can influence which baseline is checked. The principal is resolved exclusively
|
||||
* through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal`
|
||||
* already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or
|
||||
* grants. A permission id that is not registered fails closed (403) exactly like a denied
|
||||
* decision — in normal operation this route only ever hard-codes an already-registered constant,
|
||||
* but the check stays live per-request as defense in depth rather than being trusted once at
|
||||
* composition time.
|
||||
*
|
||||
* On success, the resolved `EkklesiaPrincipal` — Core's own narrow `{kind, userId}` contract, not
|
||||
* Better Auth's richer session/user object — is stored on the Hono context so the route handler
|
||||
* can build an actor without resolving the principal a second time. This is deliberately narrower
|
||||
* than the "no generalized session/user object in Hono context" concern ADR 0011 raised about
|
||||
* Better Auth's own documented example: only Core's already-public-safe principal shape is ever
|
||||
* set, never a session token, raw user row, or Better Auth type.
|
||||
*
|
||||
* A genuine infrastructure failure — principal resolution, evaluation, or the underlying
|
||||
* database — is never folded into a 403; it returns a sanitized 503, mirroring
|
||||
* `/api/v1/principal`'s existing behavior for the identical reason: a caller must never see "no
|
||||
* permission" when the true answer is "could not be determined."
|
||||
*/
|
||||
export function requirePermission(
|
||||
options: AuthorizationMiddlewareOptions,
|
||||
permissionId: PermissionId,
|
||||
): MiddlewareHandler {
|
||||
return async (c: Context, next: Next) => {
|
||||
const declaration = options.registry.get(permissionId);
|
||||
if (!declaration) {
|
||||
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
|
||||
let principal: EkklesiaPrincipal;
|
||||
try {
|
||||
principal = await options.runtime.resolvePrincipal(c.req.raw.headers);
|
||||
} catch {
|
||||
return sanitizedDenial(c, 503, "PRINCIPAL_RESOLUTION_FAILED");
|
||||
}
|
||||
|
||||
let decision: { allowed: boolean };
|
||||
try {
|
||||
decision = await options.evaluator.authorize({
|
||||
baseline: declaration.baseline,
|
||||
permission: declaration.id,
|
||||
principal,
|
||||
});
|
||||
} catch {
|
||||
return sanitizedDenial(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
|
||||
}
|
||||
|
||||
if (!decision.allowed) {
|
||||
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
|
||||
c.set(PRINCIPAL_CONTEXT_KEY, principal);
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the principal a preceding `requirePermission` middleware already resolved and verified.
|
||||
* Throws (a route-wiring bug, not a runtime condition) if called on a route this middleware never
|
||||
* protected. */
|
||||
export function getAuthorizedPrincipal(c: Context): EkklesiaPrincipal {
|
||||
const principal = c.get(PRINCIPAL_CONTEXT_KEY) as EkklesiaPrincipal | undefined;
|
||||
if (!principal) {
|
||||
throw new Error("getAuthorizedPrincipal called outside a requirePermission-protected route.");
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires an exact trusted `Origin` header for a cookie-authenticated, state-changing route.
|
||||
* CORS response headers alone are not CSRF protection — they only stop a browser from *reading* a
|
||||
* cross-origin response, not from *sending* the state-changing request in the first place — so
|
||||
* mutation routes need this explicit, server-side check in addition to (not instead of) the
|
||||
* existing CORS policy. A missing or untrusted origin is rejected before the route (or the
|
||||
* permission check) ever runs. Non-browser, non-cookie automation (service accounts/API tokens)
|
||||
* is deliberately out of scope for this slice and has no way to satisfy this check yet.
|
||||
*/
|
||||
export function requireTrustedOrigin(trustedOrigins: ReadonlySet<string>): MiddlewareHandler {
|
||||
return async (c: Context, next: Next) => {
|
||||
const origin = c.req.header("origin");
|
||||
if (!origin || !trustedOrigins.has(origin)) {
|
||||
return sanitizedDenial(c, 403, "UNTRUSTED_ORIGIN");
|
||||
}
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user