diff --git a/orchestrator/src/middleware/apiKeyAuth.ts b/orchestrator/src/middleware/apiKeyAuth.ts index 69873dd..20a9640 100644 --- a/orchestrator/src/middleware/apiKeyAuth.ts +++ b/orchestrator/src/middleware/apiKeyAuth.ts @@ -1,44 +1,145 @@ import { Request, Response, NextFunction } from "express"; +import type { ActorRole } from "../types/transactionState"; /** - * API Key authentication middleware + * 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 apiKey = req.headers["x-api-key"] || req.headers["authorization"]?.replace("Bearer ", ""); - - if (!apiKey) { + const key = extractKey(req); + if (!key) { return res.status(401).json({ error: "Unauthorized", message: "API key is required", }); } - // Validate API key (in production, check against database) - const validApiKeys = process.env.API_KEYS?.split(",") || []; - if (!validApiKeys.includes(apiKey as string)) { + const entry = getCache().get(key); + if (!entry) { return res.status(403).json({ error: "Forbidden", message: "Invalid API key", }); } - // Attach API key info to request - (req as any).apiKey = apiKey; + const r = req as Request & { apiKey?: string; actorRole?: ActorRole }; + r.apiKey = entry.key; + r.actorRole = entry.role; next(); }; /** - * Optional API key authentication (for public endpoints) + * Optional auth — injects role only when the key is valid. */ -export const optionalApiKeyAuth = (req: Request, res: Response, next: NextFunction) => { - const apiKey = req.headers["x-api-key"] || req.headers["authorization"]?.replace("Bearer ", ""); - if (apiKey) { - const validApiKeys = process.env.API_KEYS?.split(",") || []; - if (validApiKeys.includes(apiKey as string)) { - (req as any).apiKey = apiKey; - (req as any).authenticated = true; +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(); + }; +} diff --git a/orchestrator/tests/unit/apiKeyAuth.test.ts b/orchestrator/tests/unit/apiKeyAuth.test.ts new file mode 100644 index 0000000..a8c45e6 --- /dev/null +++ b/orchestrator/tests/unit/apiKeyAuth.test.ts @@ -0,0 +1,134 @@ +/** + * Tests for API-key role binding (gap v2 §7.7). + */ + +import { describe, it, expect, beforeEach, jest } from "@jest/globals"; +import type { Request, Response, NextFunction } from "express"; +import { + apiKeyAuth, + optionalApiKeyAuth, + requireRole, + __resetApiKeyCacheForTests, +} from "../../src/middleware/apiKeyAuth"; + +function makeReqRes(headers: Record = {}) { + const req = { headers } as unknown as Request; + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }) as unknown as Response["status"]; + const res = { status, json } as unknown as Response; + const next = jest.fn() as unknown as NextFunction; + return { req, res, next, status, json }; +} + +describe("apiKeyAuth role binding", () => { + beforeEach(() => { + __resetApiKeyCacheForTests(); + process.env.API_KEYS = ""; + }); + + it("rejects when no key is supplied (401)", () => { + const { req, res, next, status } = makeReqRes(); + apiKeyAuth(req, res, next); + expect(status).toHaveBeenCalledWith(401); + expect(next).not.toHaveBeenCalled(); + }); + + it("rejects when the key is not registered (403)", () => { + process.env.API_KEYS = "good-key:approver"; + const { req, res, next, status } = makeReqRes({ "x-api-key": "bad-key" }); + apiKeyAuth(req, res, next); + expect(status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + it("binds role=operator for bare keys (back-compat)", () => { + process.env.API_KEYS = "legacy-key"; + const { req, res, next } = makeReqRes({ "x-api-key": "legacy-key" }); + apiKeyAuth(req, res, next); + expect(next).toHaveBeenCalled(); + expect((req as Request & { actorRole?: string }).actorRole).toBe("operator"); + }); + + it("binds the declared role for key:role entries", () => { + process.env.API_KEYS = "k1:approver,k2:releaser,k3:validator"; + const cases: Array<[string, string]> = [ + ["k1", "approver"], + ["k2", "releaser"], + ["k3", "validator"], + ]; + for (const [key, role] of cases) { + __resetApiKeyCacheForTests(); + const { req, res, next } = makeReqRes({ "x-api-key": key }); + apiKeyAuth(req, res, next); + expect(next).toHaveBeenCalled(); + expect((req as Request & { actorRole?: string }).actorRole).toBe(role); + } + }); + + it("fails closed on unknown roles — entry is skipped", () => { + process.env.API_KEYS = "k1:root,k2:approver"; + const reject = makeReqRes({ "x-api-key": "k1" }); + apiKeyAuth(reject.req, reject.res, reject.next); + expect(reject.status).toHaveBeenCalledWith(403); + + __resetApiKeyCacheForTests(); + process.env.API_KEYS = "k1:root,k2:approver"; + const accept = makeReqRes({ "x-api-key": "k2" }); + apiKeyAuth(accept.req, accept.res, accept.next); + expect(accept.next).toHaveBeenCalled(); + expect( + (accept.req as Request & { actorRole?: string }).actorRole, + ).toBe("approver"); + }); + + it("accepts Bearer authorization header", () => { + process.env.API_KEYS = "bearer-key:releaser"; + const { req, res, next } = makeReqRes({ + authorization: "Bearer bearer-key", + }); + apiKeyAuth(req, res, next); + expect(next).toHaveBeenCalled(); + expect((req as Request & { actorRole?: string }).actorRole).toBe("releaser"); + }); + + it("re-parses the cache when API_KEYS changes", () => { + process.env.API_KEYS = "v1:approver"; + const first = makeReqRes({ "x-api-key": "v1" }); + apiKeyAuth(first.req, first.res, first.next); + expect(first.next).toHaveBeenCalled(); + + process.env.API_KEYS = "v2:releaser"; + const second = makeReqRes({ "x-api-key": "v1" }); + apiKeyAuth(second.req, second.res, second.next); + expect(second.status).toHaveBeenCalledWith(403); + }); + + it("optionalApiKeyAuth is a pass-through when no key is supplied", () => { + const { req, res, next } = makeReqRes(); + optionalApiKeyAuth(req, res, next); + expect(next).toHaveBeenCalled(); + expect((req as Request & { actorRole?: string }).actorRole).toBeUndefined(); + }); + + it("requireRole lets permitted roles through and 403s others", () => { + process.env.API_KEYS = "a:approver,r:releaser"; + const guard = requireRole("approver"); + + __resetApiKeyCacheForTests(); + const ok = makeReqRes({ "x-api-key": "a" }); + apiKeyAuth(ok.req, ok.res, ok.next); + const okNext = jest.fn() as unknown as NextFunction; + const okStatus = jest.fn().mockReturnValue({ json: jest.fn() }) as unknown as Response["status"]; + guard(ok.req, { status: okStatus } as unknown as Response, okNext); + expect(okNext).toHaveBeenCalled(); + + __resetApiKeyCacheForTests(); + const bad = makeReqRes({ "x-api-key": "r" }); + apiKeyAuth(bad.req, bad.res, bad.next); + const badNext = jest.fn() as unknown as NextFunction; + const badStatus = jest.fn().mockReturnValue({ json: jest.fn() }) as unknown as Response["status"]; + guard(bad.req, { status: badStatus } as unknown as Response, badNext); + expect(badStatus).toHaveBeenCalledWith(403); + expect(badNext).not.toHaveBeenCalled(); + }); +});