import { describe, expect, test } from "bun:test"; 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"; 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); }); });