Two related fail-closed gaps let a well-formed but never-declared permission id slip through: - POST /api/v1/grants created explicit grants for any well-formed PermissionId without checking the injected permission registry, allowing dormant grants for invented identifiers that could become effective the moment those identifiers were registered later. - createAuthorizationEvaluator validated the permission id only inside the restricted-baseline branch, so a malformed permission combined with the public or authenticated baseline could still return allowed. Both now fail closed: the grants route checks the target permission against the trusted registry before calling store.createGrant (sanitized 403, no store or audit write), and the evaluator validates permission shape before any baseline is allowed to return an allowed decision, without adding a registry dependency to the evaluator itself. The live integration lifecycle test switched from an unregistered calendar.events.read to the registered core.authorization.grants.manage permission, since module permission registration does not exist yet.
223 lines
7.9 KiB
TypeScript
223 lines
7.9 KiB
TypeScript
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);
|
|
}
|
|
|
|
// 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;
|
|
}
|