Files
WRNexusJS/packages/authz/test/engine.test.ts
T
ClintchizandClaude Opus 5 cd82bec414 fix(authz): fix perf, doc, and fail-open gaps found in second review
Re-review of Task 6's fix round 1 (plan amendment d6a2d054) found three
items in that diff plus one adjacent pre-existing issue that C1 made
reachable:

- Important (perf): permissionsFor() rebuilt the deny Set on every
  entry in the granted set (O(grants x denies) allocations on a
  per-request path). Hoisted to build the Set once. Measured
  4000x4000: 665.92ms before, 3.90ms after.
- Important (contract accuracy): permissionsFor() only half-agrees
  with decide() — a narrow deny under a broad grant (e.g. role editor's
  "post:*" plus a deny on "post:delete") can't be represented in a flat
  Set, so the set still contains "post:*" while decide() correctly
  refuses "post:delete". Documented as NOT authoritative on the
  AuthzResolver interface, and pinned with a regression test asserting
  the divergence is deliberate.
- Minor: subject.id === "" was audited as subjectId: "" instead of
  omitted, so consoleAuditSink printed a blank subject= rather than
  subject=anonymous. Reused the same non-empty-string guard as the
  decide() path.
- Important (adjacent, advanced.ts): owner() compared subject[key] to
  resource[key] with Object.is without checking either side was
  present, so two absent ids (Object.is(undefined, undefined) ===
  true) satisfied ownership. Unreachable before this task, but C1 now
  runs bound policies for anonymous/empty subjects, putting this on a
  live path. Fixed to deny whenever either side is undefined or null.

Every fix's regression test was verified by reverting the fix and
confirming the test fails against the pre-fix code before restoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:15:34 +05:30

338 lines
12 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { DecisionPolicy } from "../src/advanced.ts";
import { defineAuthz } from "../src/registry.ts";
import { mergeCatalogs } from "../src/catalog.ts";
import { memoryPermissionStore } from "../src/store.ts";
import { memoryAuditSink } from "../src/audit.ts";
import { createAuthzResolver, expandRoles, permissionMatches } from "../src/engine.ts";
import type { AuthzCatalog } from "../src/types.ts";
const catalog = mergeCatalogs([
{
source: "test.ts",
module: defineAuthz({
permissions: {
"post:read": { public: true },
"post:write": {},
"post:delete": { risk: "high" },
"post:comment:delete": {},
},
roles: {
editor: ["post:*"],
moderator: ["post:comment:*"],
admin: ["role:editor", "post:delete"],
cyclic: ["role:cyclic", "post:read"],
},
policies: {
ownsPost: async (subject: { id?: string }, resource?: { authorId?: string }) =>
resource?.authorId === subject?.id
? { allowed: true }
: { allowed: false, reason: "not the author", policy: "ownsPost" },
explodes: async () => {
throw new Error("policy blew up");
},
},
bindings: { "post:write": ["ownsPost"] },
}),
},
]);
const make = (store = memoryPermissionStore(), audit = memoryAuditSink()) => ({
store,
audit,
resolver: createAuthzResolver({ catalog, store, audit, strict: false }),
});
describe("expandRoles", () => {
test("expands wildcards and role inheritance", () => {
expect([...expandRoles(catalog, ["admin"])].sort()).toEqual(["post:*", "post:delete"]);
});
test("terminates on cyclic inheritance", () => {
expect([...expandRoles(catalog, ["cyclic"])]).toEqual(["post:read"]);
});
});
describe("permissionMatches", () => {
test("matches exact, root wildcard, and every namespace depth", () => {
expect(permissionMatches(new Set(["post:read"]), "post:read")).toBe(true);
expect(permissionMatches(new Set(["*"]), "anything:at:all")).toBe(true);
expect(permissionMatches(new Set(["post:*"]), "post:comment:delete")).toBe(true);
expect(permissionMatches(new Set(["post:comment:*"]), "post:comment:delete")).toBe(true);
expect(permissionMatches(new Set(["post:comment:*"]), "post:write")).toBe(false);
});
});
describe("createAuthzResolver.decide", () => {
test("allows a public permission for an anonymous subject", async () => {
const { resolver } = make();
const result = await resolver.decide({ subject: null, permission: "post:read" });
expect(result.allowed).toBe(true);
});
test("denies a non-public permission for an anonymous subject", async () => {
const { resolver } = make();
const result = await resolver.decide({ subject: null, permission: "post:delete" });
expect(result.allowed).toBe(false);
});
test("allows via a role-derived wildcard", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "moderator");
const result = await resolver.decide({
subject: { id: "u1" },
permission: "post:comment:delete",
});
expect(result.allowed).toBe(true);
});
test("an explicit deny beats a role and beats '*'", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "admin");
await store.grant("u1", "post:delete", "deny");
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/explicit deny/i);
});
test("a bound policy can deny a permission the role grants", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "editor");
const denied = await resolver.decide({
subject: { id: "u1" },
permission: "post:write",
resource: { authorId: "someone-else" },
});
expect(denied.allowed).toBe(false);
expect(denied.policy).toBe("ownsPost");
const allowed = await resolver.decide({
subject: { id: "u1" },
permission: "post:write",
resource: { authorId: "u1" },
});
expect(allowed.allowed).toBe(true);
});
test("a throwing policy denies rather than escaping", async () => {
const throwing = mergeCatalogs([
{
source: "t.ts",
module: defineAuthz({
permissions: { "x:go": {} },
policies: {
explodes: async () => {
throw new Error("boom");
},
},
bindings: { "x:go": ["explodes"] },
}),
},
]);
const store = memoryPermissionStore();
await store.grant("u1", "x:go", "allow");
const resolver = createAuthzResolver({ catalog: throwing, store, strict: false });
const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:go" });
expect(result.allowed).toBe(false);
});
test("a store failure denies and does not throw", async () => {
const broken = {
...memoryPermissionStore(),
assignmentsFor: async () => {
throw new Error("db down");
},
};
const resolver = createAuthzResolver({ catalog, store: broken, strict: false });
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
expect(result.allowed).toBe(false);
});
test("an unregistered permission denies when strict is off", async () => {
const { resolver } = make();
const result = await resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" });
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/not registered/i);
});
test("an unregistered permission throws when strict is on", async () => {
const resolver = createAuthzResolver({
catalog,
store: memoryPermissionStore(),
strict: true,
});
await expect(
resolver.decide({ subject: { id: "u1" }, permission: "ghost:perm" }),
).rejects.toThrow(/ghost:perm/);
});
test("denials are audited and allows are not, by default", async () => {
const { store, audit, resolver } = make();
await store.assignRole("u1", "moderator");
await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
await resolver.decide({ subject: { id: "u1" }, permission: "post:read" });
expect(audit.events).toHaveLength(1);
expect(audit.events[0]!.allowed).toBe(false);
});
test("auditAllows records both verdicts", async () => {
const store = memoryPermissionStore();
const audit = memoryAuditSink();
const resolver = createAuthzResolver({
catalog,
store,
audit,
strict: false,
auditAllows: true,
});
await resolver.decide({ subject: null, permission: "post:read" });
expect(audit.events).toHaveLength(1);
expect(audit.events[0]!.allowed).toBe(true);
});
test("tenant scope selects the right assignments", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "editor", { tenantId: "t1" });
const inside = await resolver.decide({
subject: { id: "u1" },
permission: "post:write",
resource: { authorId: "u1" },
scope: { tenantId: "t1" },
});
const outside = await resolver.decide({
subject: { id: "u1" },
permission: "post:write",
resource: { authorId: "u1" },
scope: { tenantId: "t2" },
});
expect(inside.allowed).toBe(true);
expect(outside.allowed).toBe(false);
});
});
describe("createAuthzResolver fail-closed regressions", () => {
test("a public permission bound to an always-denying policy denies for an anonymous subject", async () => {
const publicPolicyCatalog = mergeCatalogs([
{
source: "pub.ts",
module: defineAuthz({
permissions: { "feed:view": { public: true } },
policies: {
neverAllow: async () => ({
allowed: false,
reason: "embargoed",
policy: "neverAllow",
}),
},
bindings: { "feed:view": ["neverAllow"] },
}),
},
]);
const resolver = createAuthzResolver({
catalog: publicPolicyCatalog,
store: memoryPermissionStore(),
strict: false,
});
const result = await resolver.decide({ subject: null, permission: "feed:view" });
expect(result.allowed).toBe(false);
expect(result.policy).toBe("neverAllow");
});
test("a policy returning a truthy non-boolean 'allowed' denies", async () => {
const truthyCatalog = mergeCatalogs([
{
source: "truthy.ts",
module: defineAuthz({
permissions: { "x:truthy": {} },
policies: {
truthy: (async () => ({ allowed: "yes" })) as unknown as DecisionPolicy<never, never>,
},
bindings: { "x:truthy": ["truthy"] },
}),
},
]);
const store = memoryPermissionStore();
await store.grant("u1", "x:truthy", "allow");
const resolver = createAuthzResolver({ catalog: truthyCatalog, store, strict: false });
const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:truthy" });
expect(result.allowed).toBe(false);
});
test("a binding naming a policy the catalog lacks denies", async () => {
const missingPolicyCatalog: AuthzCatalog = {
permissions: new Map([["x:missing", {}]]),
roles: new Map(),
policies: new Map(),
attributes: new Map(),
bindings: new Map([["x:missing", ["ghostPolicy"]]]),
};
const store = memoryPermissionStore();
await store.grant("u1", "x:missing", "allow");
const resolver = createAuthzResolver({ catalog: missingPolicyCatalog, store, strict: false });
const result = await resolver.decide({ subject: { id: "u1" }, permission: "x:missing" });
expect(result.allowed).toBe(false);
expect(result.policy).toBe("ghostPolicy");
});
test("a wildcard deny blocks a permission the role explicitly grants", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "admin");
await store.grant("u1", "post:*", "deny");
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/explicit deny/i);
});
test("permissionsFor subtracts permissions covered by a wildcard deny", async () => {
const { store, resolver } = make();
await store.assignRole("u1", "editor");
await store.grant("u1", "post:*", "deny");
const granted = await resolver.permissionsFor("u1");
expect(permissionMatches(granted, "post:delete")).toBe(false);
});
test("permissionsFor cannot represent a narrow deny under a broad grant (decide remains authoritative)", async () => {
// A set of strings can't express "post:* except post:delete": the grant
// entry "post:*" survives the subtraction (it isn't itself covered by the
// narrower deny "post:delete"), so a set-based check would wrongly say
// this permission is available. decide() has no such limitation — it
// checks the specific permission against the deny list directly, not
// through the granted-entries set — and correctly refuses it. This is a
// pinned, deliberate divergence, not a bypass: callers must gate
// individual actions with decide()/can(), never by matching this set.
const { store, resolver } = make();
await store.assignRole("u1", "editor");
await store.grant("u1", "post:delete", "deny");
const granted = await resolver.permissionsFor("u1");
expect(granted.has("post:*")).toBe(true);
expect(permissionMatches(granted, "post:delete")).toBe(true);
const decision = await resolver.decide({ subject: { id: "u1" }, permission: "post:delete" });
expect(decision.allowed).toBe(false);
expect(decision.reason).toMatch(/explicit deny/i);
});
test("non-string subject ids deny rather than falling back to anonymous", async () => {
const { resolver } = make();
const invalidIds: unknown[] = [0, "", 123, {}];
for (const id of invalidIds) {
const result = await resolver.decide({
subject: { id } as unknown as { id?: string },
permission: "post:read",
});
expect(result.allowed).toBe(false);
expect(result.reason).toMatch(/invalid subject/i);
}
});
test("an empty-string subject id is not recorded as the audited subjectId", async () => {
const { audit, resolver } = make();
await resolver.decide({
subject: { id: "" } as unknown as { id?: string },
permission: "post:read",
});
expect(audit.events).toHaveLength(1);
expect(audit.events[0]!.subjectId).toBeUndefined();
});
});