feat: add authorization bootstrap and HTTP enforcement

Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
ekklesia
2026-07-23 11:16:32 -05:00
parent 72e082ff2f
commit 1363608552
33 changed files with 2962 additions and 63 deletions
+212
View File
@@ -0,0 +1,212 @@
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
import {
createUserActor,
isAuthenticatedPrincipal,
safeParseAuthorizationScope,
safeParseEkklesiaUserId,
safeParseGrantId,
safeParsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
AuthorizationStoreError,
CORE_AUTHORIZATION_GRANTS_MANAGE,
type GrantRecord,
} from "@ekklesia/core/authorization";
import { Hono } from "hono";
import type { Context } from "hono";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
readonly store: AuthorizationStore;
readonly trustedOrigins: ReadonlySet<string>;
}
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
c.header("Cache-Control", "no-store");
if (error instanceof AuthorizationStoreError) {
const status = error.code === "AUTHORIZATION_GRANT_NOT_FOUND" ? 404 : 503;
return c.json({ code: error.code }, status);
}
return c.json({ code: "INTERNAL_ERROR" }, 503);
}
interface ParsedCreateGrantBody {
readonly expiresAt?: Date;
readonly permission: PermissionId;
readonly scope?: AuthorizationScope;
readonly subjectUserId: EkklesiaUserId;
}
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "scope", "expiresAt"]);
/**
* Accepts only `subjectUserId`, `permission`, an optional `scope`, and an optional `expiresAt`.
* Any other key present — `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`,
* `revokedAt`, a grant id, or anything claiming owner/effective-permission state — makes the
* whole body invalid rather than being silently dropped, so a caller cannot smuggle
* server-derived fields through a request that otherwise looks well-formed.
*/
function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return undefined;
}
const body = raw as Record<string, unknown>;
if (Object.keys(body).some((key) => !CREATE_GRANT_ALLOWED_KEYS.has(key))) {
return undefined;
}
const subjectUserId = safeParseEkklesiaUserId(body.subjectUserId);
if (!subjectUserId.success) {
return undefined;
}
const permission = safeParsePermissionId(body.permission);
if (!permission.success) {
return undefined;
}
let scope: AuthorizationScope | undefined;
if (body.scope !== undefined) {
const parsedScope = safeParseAuthorizationScope(body.scope);
if (!parsedScope.success) {
return undefined;
}
scope = parsedScope.data;
}
let expiresAt: Date | undefined;
if (body.expiresAt !== undefined) {
if (typeof body.expiresAt !== "string") {
return undefined;
}
const parsedDate = new Date(body.expiresAt);
if (Number.isNaN(parsedDate.getTime())) {
return undefined;
}
expiresAt = parsedDate;
}
return Object.freeze({
...(expiresAt === undefined ? {} : { expiresAt }),
permission: permission.data,
...(scope === undefined ? {} : { scope }),
subjectUserId: subjectUserId.data,
});
}
function serializeGrant(grant: GrantRecord) {
return {
createdAt: grant.createdAt.toISOString(),
expiresAt: grant.expiresAt ? grant.expiresAt.toISOString() : null,
id: grant.id,
permission: grant.permission,
revokedAt: grant.revokedAt ? grant.revokedAt.toISOString() : null,
scope: grant.scope ?? null,
sourceKind: grant.sourceKind,
userId: grant.userId,
};
}
/**
* The v1 explicit-grant management API: list, create, and revoke *explicit* grants only. Every
* route requires `core.authorization.grants.manage`; a platform owner passes through the
* evaluator's existing owner bypass without ever needing an explicit grant of this permission
* themselves — that bypass is exactly how a platform owner deliberately delegates grant
* administration to another account by granting them this one permission.
*
* Relationship-derived grants are never reachable through this API in either direction: creation
* always hard-codes `sourceKind: "explicit"` server-side (the request body cannot supply a
* `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting
* to revoke a relationship-derived grant by id behaves exactly like the grant does not exist —
* those grants remain owned by the module operation that established the relationship.
*/
export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
const routes = new Hono();
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
routes.get("/api/v1/grants", requireManagePermission, async (c) => {
c.header("Cache-Control", "no-store");
const subjectUserId = safeParseEkklesiaUserId(c.req.query("userId"));
if (!subjectUserId.success) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
try {
const grants = await options.store.listGrantsForUser(subjectUserId.data, {
sourceKind: "explicit",
});
return c.json({ grants: grants.map(serializeGrant) }, 200);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
});
routes.post("/api/v1/grants", requireOrigin, requireManagePermission, async (c) => {
c.header("Cache-Control", "no-store");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
let rawBody: unknown;
try {
rawBody = await c.req.json();
} catch {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
const parsed = parseCreateGrantBody(rawBody);
if (!parsed) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
try {
const grant = await options.store.createGrant({
actor: createUserActor(principal.userId),
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
permission: parsed.permission,
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
sourceKind: "explicit",
userId: parsed.subjectUserId,
});
return c.json({ grant: serializeGrant(grant) }, 201);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
});
routes.post(
"/api/v1/grants/:grantId/revoke",
requireOrigin,
requireManagePermission,
async (c) => {
c.header("Cache-Control", "no-store");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
const grantId = safeParseGrantId(c.req.param("grantId"));
if (!grantId.success) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
try {
await options.store.revokeGrant({
actor: createUserActor(principal.userId),
grantId: grantId.data,
sourceKind: "explicit",
});
return c.json({ success: true }, 200);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
},
);
return routes;
}