fix(authz): stop authorizeDecision leaking policy names in 403 bodies

This commit is contained in:
2026-08-04 19:17:48 +05:30
parent 3f1fcd0d2d
commit e15422ed8d
2 changed files with 44 additions and 2 deletions
+13 -1
View File
@@ -77,14 +77,26 @@ export function allDecisions<S, R>(...policies: DecisionPolicy<S, R>[]): 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<AuthorizationDecision>,
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 },
);
};
+31 -1
View File
@@ -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<string, unknown>;
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");
});
});