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; }