import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, parsePermissionId, } from "@ekklesia/contracts"; import { type AuthorizationEvaluator, createPermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { Hono } from "hono"; import { describe, expect, it, vi } from "vitest"; import { type AuthorizationMiddlewareOptions, getAuthorizedPrincipal, requirePermission, requireTrustedOrigin, } from "../src/authorization-middleware.js"; const testPermission = parsePermissionId("core.test.restricted-thing"); const unregisteredPermission = parsePermissionId("core.test.unregistered-thing"); const registry = createPermissionRegistry([ { baseline: "restricted", description: "A restricted test permission.", id: testPermission }, ]); function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { return { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), ...overrides, }; } function fakeEvaluator(overrides: Partial = {}): AuthorizationEvaluator { return { authorize: vi.fn().mockResolvedValue({ allowed: true }), ...overrides, }; } function buildTestApp(options: Partial = {}) { const runtime = options.runtime ?? fakeRuntime(); const evaluator = options.evaluator ?? fakeEvaluator(); const middlewareOptions: AuthorizationMiddlewareOptions = { evaluator, registry, runtime }; const app = new Hono(); app.get("/protected", requirePermission(middlewareOptions, testPermission), (c) => { const principal = getAuthorizedPrincipal(c); return c.json({ principalKind: principal.kind }); }); app.get("/unregistered", requirePermission(middlewareOptions, unregisteredPermission), (c) => c.json({ ok: true }), ); app.get("/unprotected", (c) => { let threw = false; try { getAuthorizedPrincipal(c); } catch { threw = true; } return c.json({ threw }); }); return { app, evaluator, runtime }; } describe("requirePermission", () => { it("allows the request through and stores the resolved principal on context when the decision is allowed", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")), }); const { app } = buildTestApp({ runtime }); const response = await app.request("/protected"); expect(response.status).toBe(200); expect(await response.json()).toEqual({ principalKind: "authenticated" }); }); it("returns a sanitized 403 when the evaluator denies", async () => { const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) }); const { app } = buildTestApp({ evaluator }); const response = await app.request("/protected"); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(response.headers.get("cache-control")).toBe("no-store"); }); it("returns a sanitized 403 for an unregistered permission without ever resolving the principal", async () => { const runtime = fakeRuntime(); const { app } = buildTestApp({ runtime }); const response = await app.request("/unregistered"); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(runtime.resolvePrincipal).not.toHaveBeenCalled(); }); it("returns a sanitized 503, not a 403, when principal resolution fails", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")), }); const { app } = buildTestApp({ runtime }); const response = await app.request("/protected"); expect(response.status).toBe(503); expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" }); }); it("returns a sanitized 503, not a 403, when evaluation itself fails", async () => { const evaluator = fakeEvaluator({ authorize: vi.fn().mockRejectedValue(new Error("connection terminated")), }); const { app } = buildTestApp({ evaluator }); const response = await app.request("/protected"); expect(response.status).toBe(503); expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" }); }); it("never leaks a raw infrastructure error message through either sanitized 503", async () => { const hostileMessage = "password=hunter2 host=db.internal.example port=5432"; const evaluator = fakeEvaluator({ authorize: vi.fn().mockRejectedValue(new Error(hostileMessage)), }); const { app } = buildTestApp({ evaluator }); const response = await app.request("/protected"); const text = await response.text(); expect(text).not.toContain("hunter2"); expect(text).not.toContain("db.internal.example"); }); it("passes the registry's own baseline and permission id to the evaluator, never a client-supplied value", async () => { const evaluator = fakeEvaluator(); const { app } = buildTestApp({ evaluator }); // A hostile client cannot even express "use this baseline instead" — there is no request // field the middleware reads for it — but sending an unrelated header proves it is ignored. await app.request("/protected", { headers: { "x-baseline": "public" } }); expect(evaluator.authorize).toHaveBeenCalledWith( expect.objectContaining({ baseline: "restricted", permission: testPermission }), ); }); }); describe("requirePermission with module-owned declarations", () => { // requirePermission has no concept of "Core" versus "module" — it only ever consults whatever // registry it is given. These declarations are shaped exactly like what // composePlatformPermissionRegistry would contribute from a real installed module (an id // namespaced under a module, not `core.*`), proving the shared enforcement mechanism works // identically for module-owned permissions without any module-specific code path. const modulePublicPermission = parsePermissionId("calendar.events.list"); const moduleAuthenticatedPermission = parsePermissionId("calendar.events.subscribe"); const moduleRestrictedPermission = parsePermissionId("calendar.events.read"); const moduleRegistry = createPermissionRegistry([ { baseline: "public", description: "List public calendar events.", id: modulePublicPermission, }, { baseline: "authenticated", description: "Subscribe to calendar event updates.", id: moduleAuthenticatedPermission, }, { baseline: "restricted", description: "Read calendar events.", id: moduleRestrictedPermission, }, ]); function buildModuleApp( permissionId: typeof modulePublicPermission, options: Partial = {}, ) { const runtime = options.runtime ?? fakeRuntime(); const evaluator = options.evaluator ?? fakeEvaluator(); const middlewareOptions: AuthorizationMiddlewareOptions = { evaluator, registry: moduleRegistry, runtime, }; const app = new Hono(); app.get("/module-protected", requirePermission(middlewareOptions, permissionId), (c) => c.json({ ok: true }), ); return { app, evaluator, runtime }; } it("resolves a module public permission's trusted baseline and passes it to the evaluator unchanged", async () => { const evaluator = fakeEvaluator(); const { app } = buildModuleApp(modulePublicPermission, { evaluator }); const response = await app.request("/module-protected"); expect(response.status).toBe(200); expect(evaluator.authorize).toHaveBeenCalledWith( expect.objectContaining({ baseline: "public", permission: modulePublicPermission }), ); }); it("resolves a module authenticated permission's trusted baseline and passes it to the evaluator unchanged", async () => { const evaluator = fakeEvaluator(); const { app } = buildModuleApp(moduleAuthenticatedPermission, { evaluator }); await app.request("/module-protected"); expect(evaluator.authorize).toHaveBeenCalledWith( expect.objectContaining({ baseline: "authenticated", permission: moduleAuthenticatedPermission, }), ); }); it("resolves a module restricted permission's trusted baseline and passes it to the evaluator unchanged", async () => { const evaluator = fakeEvaluator(); const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator }); await app.request("/module-protected"); expect(evaluator.authorize).toHaveBeenCalledWith( expect.objectContaining({ baseline: "restricted", permission: moduleRestrictedPermission, }), ); }); it("allows an anonymous principal for a module permission the evaluator authorizes", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), }); const { app } = buildModuleApp(modulePublicPermission, { runtime }); const response = await app.request("/module-protected"); expect(response.status).toBe(200); }); it("allows an authenticated principal for a module permission the evaluator authorizes", async () => { const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")), }); const { app } = buildModuleApp(moduleRestrictedPermission, { runtime }); const response = await app.request("/module-protected"); expect(response.status).toBe(200); }); it("denies a module permission the evaluator does not authorize", async () => { const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) }); const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator }); const response = await app.request("/module-protected"); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); }); it("denies a module permission absent from the registry before ever resolving the principal or consulting the evaluator", async () => { const runtime = fakeRuntime(); const evaluator = fakeEvaluator(); const absentModulePermission = parsePermissionId("scheduler.jobs.read"); const { app } = buildModuleApp(absentModulePermission, { evaluator, runtime }); const response = await app.request("/module-protected"); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(runtime.resolvePrincipal).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled(); }); it("never lets a client-supplied baseline influence the decision for a module permission", async () => { const evaluator = fakeEvaluator(); const { app } = buildModuleApp(moduleRestrictedPermission, { evaluator }); // A hostile client cannot make a restricted module permission behave as public — there is no // request field the middleware reads for a baseline override. await app.request("/module-protected", { headers: { "x-baseline": "public", "x-permission-baseline": "public" }, }); expect(evaluator.authorize).toHaveBeenCalledWith( expect.objectContaining({ baseline: "restricted" }), ); }); }); describe("getAuthorizedPrincipal", () => { it("throws when called on a route requirePermission never protected", async () => { const { app } = buildTestApp(); const response = await app.request("/unprotected"); expect(await response.json()).toEqual({ threw: true }); }); }); describe("requireTrustedOrigin", () => { const trustedOrigins = new Set(["https://dashboard.example.test"]); function buildOriginApp() { const app = new Hono(); app.post("/mutate", requireTrustedOrigin(trustedOrigins), (c) => c.json({ ok: true })); return app; } it("allows a request from a trusted origin", async () => { const app = buildOriginApp(); const response = await app.request("/mutate", { headers: { origin: "https://dashboard.example.test" }, method: "POST", }); expect(response.status).toBe(200); }); it("rejects a request with no Origin header at all", async () => { const app = buildOriginApp(); const response = await app.request("/mutate", { method: "POST" }); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); }); it("rejects a request from an untrusted origin", async () => { const app = buildOriginApp(); const response = await app.request("/mutate", { headers: { origin: "https://evil.example.test" }, method: "POST", }); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); }); });