From 6d8b6daba9bde2cd0195fadb3714dfea3c8edcf4 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 17:40:19 +0530 Subject: [PATCH] feat(authz): add resolution engine with deny-wins precedence and fail-closed errors --- packages/authz/src/engine.ts | 166 +++++++++++++++++++++++ packages/authz/test/engine.test.ts | 208 +++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 packages/authz/src/engine.ts create mode 100644 packages/authz/test/engine.test.ts diff --git a/packages/authz/src/engine.ts b/packages/authz/src/engine.ts new file mode 100644 index 00000000..ef5ae6d0 --- /dev/null +++ b/packages/authz/src/engine.ts @@ -0,0 +1,166 @@ +import type { AuthorizationDecision } from "./advanced.ts"; +import { safeRecord, type AuthzAuditSink } from "./audit.ts"; +import type { PermissionStore } from "./store.ts"; +import type { AuthzCatalog, AuthzScope } from "./types.ts"; + +export interface AuthzResolverOptions { + catalog: AuthzCatalog; + store: PermissionStore; + audit?: AuthzAuditSink; + /** + * Throw on an unregistered permission instead of denying. Defaults to true + * outside production, so typos surface during development. + */ + strict?: boolean; + /** Record allows as well as denies. Off by default to bound write volume. */ + auditAllows?: boolean; +} + +export interface DecideInput { + subject: { id?: string; [key: string]: unknown } | null | undefined; + permission: string; + resource?: unknown; + scope?: AuthzScope; +} + +export interface AuthzResolver { + permissionsFor(subjectId: string, scope?: AuthzScope): Promise>; + decide(input: DecideInput): Promise; +} + +/** Expand roles into their granted entries, following `role:` and stopping on cycles. */ +export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Set { + const out = new Set(); + const seen = new Set(); + const walk = (role: string) => { + if (seen.has(role)) return; + seen.add(role); + for (const entry of catalog.roles.get(role) ?? []) { + if (entry.startsWith("role:")) walk(entry.slice(5)); + else out.add(entry); + } + }; + for (const role of roles) walk(role); + return out; +} + +/** Exact match, root wildcard, or a namespace wildcard at any depth. */ +export function permissionMatches(granted: Set, permission: string): boolean { + if (granted.has("*") || granted.has(permission)) return true; + for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) { + if (granted.has(`${permission.slice(0, at)}:*`)) return true; + } + return false; +} + +function isProduction(): boolean { + return (process.env.NODE_ENV ?? "development") === "production"; +} + +export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolver { + const { catalog, store, audit } = options; + const strict = options.strict ?? !isProduction(); + + /** + * Single source of truth for "what does this subject hold?". Returns the + * raw assignments too, because `decide` needs `denies` and `permissionsFor` + * does not — do NOT duplicate this logic in either caller. + */ + const loadEffective = async (subjectId: string, scope?: AuthzScope) => { + const assignments = await store.assignmentsFor(subjectId, scope); + const granted = expandRoles(catalog, assignments.roles); + for (const grant of assignments.grants) granted.add(grant); + return { assignments, granted }; + }; + + const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => + (await loadEffective(subjectId, scope)).granted; + + const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => { + if (!result.allowed || options.auditAllows) { + safeRecord(audit, { + subjectId: input.subject?.id, + scope: input.scope, + permission: input.permission, + allowed: result.allowed, + reason: result.reason, + policy: result.policy, + at: Date.now(), + }); + } + return result; + }; + + return { + permissionsFor, + + async decide(input) { + const { subject, permission, resource, scope } = input; + const meta = catalog.permissions.get(permission); + + if (!meta) { + if (strict) { + throw new Error( + `WRN-AUTHZ-UNKNOWN: permission '${permission}' is not registered. ` + + `Declare it with defineAuthz() in app/authz/.`, + ); + } + return finish(input, { + allowed: false, + reason: `Permission '${permission}' is not registered`, + }); + } + + const subjectId = subject?.id; + if (!subjectId) { + return finish( + input, + meta.public + ? { allowed: true, reason: "public permission" } + : { allowed: false, reason: "Authentication required" }, + ); + } + + let assignments; + let granted: Set; + try { + ({ assignments, granted } = await loadEffective(subjectId, scope)); + } catch (error) { + console.error("[wrnexus:authz] permission store failed; denying", error); + return finish(input, { allowed: false, reason: "Authorization store unavailable" }); + } + + // 1. Explicit deny wins over everything, including "*". + if (assignments.denies.includes(permission)) { + return finish(input, { allowed: false, reason: "explicit deny" }); + } + + // 2. Must hold the permission at all. + if (!meta.public && !permissionMatches(granted, permission)) { + return finish(input, { allowed: false, reason: "Missing permission" }); + } + + // 3. Every bound policy must pass. + for (const name of catalog.bindings.get(permission) ?? []) { + const policy = catalog.policies.get(name); + if (!policy) continue; + try { + const verdict = await ( + policy as unknown as ( + s: unknown, + r: unknown, + ) => AuthorizationDecision | Promise + )(subject, resource); + if (!verdict.allowed) { + return finish(input, { ...verdict, policy: verdict.policy ?? name }); + } + } catch (error) { + console.error(`[wrnexus:authz] policy '${name}' threw; denying`, error); + return finish(input, { allowed: false, reason: "Policy error", policy: name }); + } + } + + return finish(input, { allowed: true }); + }, + }; +} diff --git a/packages/authz/test/engine.test.ts b/packages/authz/test/engine.test.ts new file mode 100644 index 00000000..c085ec9d --- /dev/null +++ b/packages/authz/test/engine.test.ts @@ -0,0 +1,208 @@ +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); + }); +});