diff --git a/packages/authz/src/advanced.ts b/packages/authz/src/advanced.ts index 3a88c58f..a0f01394 100644 --- a/packages/authz/src/advanced.ts +++ b/packages/authz/src/advanced.ts @@ -77,14 +77,26 @@ export function allDecisions(...policies: DecisionPolicy[]): Decisio return allow("all policies passed"); }; } +export interface AuthorizeDecisionOptions { + /** + * Include `reason` and `policy` in the 403 body. Off by default: policy + * names describe internal authorization structure and should not reach an + * unauthenticated caller. + */ + exposeReason?: boolean; +} + export function authorizeDecision( evaluate: (ctx: Context) => AuthorizationDecision | Promise, + options: AuthorizeDecisionOptions = {}, ): Middleware { return async (ctx, next) => { const result = await evaluate(ctx); if (result.allowed) return next(); return Response.json( - { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy }, + options.exposeReason + ? { ok: false, error: "Forbidden", reason: result.reason, policy: result.policy } + : { ok: false, error: "Forbidden" }, { status: 403 }, ); }; diff --git a/packages/authz/test/authz.test.ts b/packages/authz/test/authz.test.ts index fc42c2ef..e3940f38 100644 --- a/packages/authz/test/authz.test.ts +++ b/packages/authz/test/authz.test.ts @@ -1,9 +1,10 @@ -import { test, expect } from "bun:test"; +import { test, expect, describe } from "bun:test"; import { createContext } from "@wrnexus/core"; import { defineRbac, hasRole, authorize, + authorizeDecision, requireRole, requirePermission, any, @@ -128,3 +129,32 @@ test("owner() denies rather than matching two absent ids", async () => { expect((await customOwns({ id: "u1" }, { ownerId: "u1" })).allowed).toBe(true); expect((await customOwns({ id: "u1" }, {})).allowed).toBe(false); }); + +describe("authorizeDecision disclosure", () => { + const ctx = { user: { id: "u1" } } as unknown as import("@wrnexus/core").Context; + const denier = async () => ({ allowed: false, reason: "secret internal rule", policy: "isVip" }); + + test("does not leak reason or policy by default", async () => { + const res = await authorizeDecision(denier)(ctx, async () => new Response("ok")); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ ok: false, error: "Forbidden" }); + }); + + test("exposeReason opts back in", async () => { + const res = await authorizeDecision(denier, { exposeReason: true })( + ctx, + async () => new Response("ok"), + ); + const body = (await res.json()) as Record; + expect(body.reason).toBe("secret internal rule"); + expect(body.policy).toBe("isVip"); + }); + + test("still calls next when allowed", async () => { + const res = await authorizeDecision(async () => ({ allowed: true }))( + ctx, + async () => new Response("passed"), + ); + expect(await res.text()).toBe("passed"); + }); +});