import { Request, Response, NextFunction } from "express"; import type { ActorRole } from "../types/transactionState"; /** * API-key authentication middleware with role binding. * * Closes gap-analysis v2 ยง7.7: API-key middleware used to authenticate * requests but never bound the caller to an ActorRole, so segregation- * of-duties enforcement at the state-transition layer had to fall back * to user-agent-level checks. * * API_KEYS format (back-compat): * API_KEYS="keyA,keyB:approver,keyC:releaser,keyD:validator" * * Each entry is either `key` (defaults to role=operator) or `key:role` * where role โˆˆ ActorRole. Unknown roles fail parsing and the key is * rejected as if it were missing โ€” fail-closed rather than silently * granting a broader role. */ interface ApiKeyEntry { key: string; role: ActorRole; } const KNOWN_ROLES: ReadonlySet = new Set([ "coordinator", "approver", "releaser", "validator", "exception_manager", "operator", ]); let cache: ReadonlyMap | undefined; let cachedRaw: string | undefined; function parseApiKeys(raw: string): ReadonlyMap { const out = new Map(); for (const item of raw.split(",").map((s) => s.trim()).filter(Boolean)) { const [key, roleRaw] = item.split(":"); if (!key) continue; const role = (roleRaw ?? "operator").trim() as ActorRole; if (!KNOWN_ROLES.has(role)) { // Fail-closed: skip entries with unknown roles rather than // silently promoting to operator. continue; } out.set(key, { key, role }); } return out; } function getCache(): ReadonlyMap { const raw = process.env.API_KEYS ?? ""; if (cache === undefined || raw !== cachedRaw) { cache = parseApiKeys(raw); cachedRaw = raw; } return cache; } export function __resetApiKeyCacheForTests(): void { cache = undefined; cachedRaw = undefined; } function extractKey(req: Request): string | undefined { const header = (req.headers["x-api-key"] as string | undefined) ?? ((req.headers["authorization"] as string | undefined)?.replace( /^Bearer\s+/i, "", )); return header?.trim() || undefined; } /** * Required API-key auth. Injects `req.apiKey` and `req.actorRole`. */ export const apiKeyAuth = (req: Request, res: Response, next: NextFunction) => { const key = extractKey(req); if (!key) { return res.status(401).json({ error: "Unauthorized", message: "API key is required", }); } const entry = getCache().get(key); if (!entry) { return res.status(403).json({ error: "Forbidden", message: "Invalid API key", }); } const r = req as Request & { apiKey?: string; actorRole?: ActorRole }; r.apiKey = entry.key; r.actorRole = entry.role; next(); }; /** * Optional auth โ€” injects role only when the key is valid. */ export const optionalApiKeyAuth = ( req: Request, _res: Response, next: NextFunction, ) => { const key = extractKey(req); if (key) { const entry = getCache().get(key); if (entry) { const r = req as Request & { apiKey?: string; actorRole?: ActorRole; authenticated?: boolean; }; r.apiKey = entry.key; r.actorRole = entry.role; r.authenticated = true; } } next(); }; /** * Guard: require that the authenticated caller carries one of the * specified roles. Returns 403 otherwise. */ export function requireRole(...allowed: ActorRole[]) { const set = new Set(allowed); return (req: Request, res: Response, next: NextFunction) => { const role = (req as Request & { actorRole?: ActorRole }).actorRole; if (!role || !set.has(role)) { return res.status(403).json({ error: "Forbidden", message: `role ${role ?? "(none)"} is not permitted for this action`, }); } next(); }; }