Files
WRNexusJS/packages/authz/test/engine.test.ts
T
ClintchizandClaude Opus 5 ae37c9b57a fix(authz): close fail-open engine gaps found in review
Coordinator review of Task 6's resolution engine (plan amendment
86b3dc1e) found two critical and four important defects, all inherited
from the brief's original engine snippet:

- C1: anonymous callers on a public permission returned allow before
  running bound policies, so the least-trusted caller got the weakest
  evaluation. Policies now run for anonymous subjects too.
- C2: the policy verdict check was a truthiness test (`!verdict.allowed`),
  so a policy returning `{allowed: "yes"}` granted access. Now requires
  `verdict?.allowed === true` exactly, and no longer spreads the raw
  verdict into the decision (which leaked arbitrary policy fields).
- I1: a binding naming a policy the catalog doesn't have was silently
  `continue`d, granting whatever the policy was meant to guard. Now
  denies with "Policy unavailable".
- I3: denies were checked by exact string equality, so a wildcard deny
  (e.g. "post:*") was accepted and silently did nothing. Denies now go
  through the same depth-aware wildcard matching as grants, via the new
  exported `deniedBy()`.
- I2: `permissionsFor` now subtracts denied entries so it agrees with
  `decide()` — needed for Task 7's UI gating to compose correctly.
- I4: non-string/empty `subject.id` (0, "", 123, {}) no longer silently
  falls back to anonymous; it denies with "Invalid subject". `subject:
  null` (no subject at all) remains genuinely anonymous.

Added six regression tests, each verified by reverting its fix and
confirming the test fails against the old code before restoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:58:28 +05:30

306 lines
11 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("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);
}
});
});