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, }, 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(); }); });