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>
306 lines
11 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|