guardPermission's getResource catch returned 403 directly, never reaching decideFor -> decide -> finish, so the audit sink never saw it — an attacker probing ids that make the resource loader throw got a clean 403 stream invisible to the audit trail. The audit sink is now stashed on the per-request RequestAuthz object (authzMiddleware already receives it via AuthzResolverOptions), and the catch records an "allowed: false" event with an opaque reason before returning the 403. Also: the explicit-deny check sat outside decide()'s try/catch, and deniedBy() guarded on denies.length rather than Array.isArray(denies). A store returning denies as a bare string let new Set(denies) iterate characters instead of the permission, so the deny matched nothing and was silently discarded; a store omitting denies entirely threw straight out of decide(). Both are now validated and handled inside the try, denying via the same "Authorization store unavailable" path as any other store failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
382 lines
15 KiB
TypeScript
382 lines
15 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("a store returning a non-array `denies` (e.g. a string) denies rather than silently allowing", async () => {
|
|
// new Set("post:write") would iterate CHARACTERS, not the permission, so
|
|
// a store returning a malformed `denies` shape must not let an otherwise
|
|
// role-granted permission slip through as allowed. Uses "post:comment:delete"
|
|
// (granted via the "moderator" role's "post:comment:*" wildcard) rather
|
|
// than "post:write", specifically because "post:write" is bound to the
|
|
// "ownsPost" policy in this test catalog — a resource-ownership check
|
|
// that would itself deny an unowned resource and mask the exact bug this
|
|
// test exists to catch, passing for the wrong reason even without the fix.
|
|
const store = memoryPermissionStore();
|
|
await store.assignRole("u1", "moderator"); // moderator -> post:comment:* wildcard grant
|
|
const malformed = {
|
|
...store,
|
|
assignmentsFor: async (subjectId: string, scope?: { tenantId?: string }) => {
|
|
const real = await store.assignmentsFor(subjectId, scope);
|
|
return { ...real, denies: "post:comment:delete" as unknown as string[] };
|
|
},
|
|
};
|
|
const resolver = createAuthzResolver({ catalog, store: malformed, strict: false });
|
|
const result = await resolver.decide({
|
|
subject: { id: "u1" },
|
|
permission: "post:comment:delete",
|
|
});
|
|
expect(result.allowed).toBe(false);
|
|
});
|
|
|
|
test("a store omitting `denies` entirely denies rather than throwing out of decide()", async () => {
|
|
const store = memoryPermissionStore();
|
|
await store.assignRole("u1", "editor");
|
|
const malformed = {
|
|
...store,
|
|
assignmentsFor: async (subjectId: string, scope?: { tenantId?: string }) => {
|
|
const real = await store.assignmentsFor(subjectId, scope);
|
|
const { denies: _denies, ...withoutDenies } = real;
|
|
return withoutDenies as unknown as typeof real;
|
|
},
|
|
};
|
|
const resolver = createAuthzResolver({ catalog, store: malformed, strict: false });
|
|
// If decide() still threw/rejected instead of denying, this `await` would
|
|
// reject and fail the test right here rather than reaching the assertion.
|
|
const result = await resolver.decide({ subject: { id: "u1" }, permission: "post:write" });
|
|
expect(result.allowed).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);
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|