diff --git a/packages/authz/src/catalog.ts b/packages/authz/src/catalog.ts new file mode 100644 index 00000000..0ffb77b5 --- /dev/null +++ b/packages/authz/src/catalog.ts @@ -0,0 +1,119 @@ +import type { AttributeMeta, AuthzCatalog, AuthzModule, PermissionMeta } from "./types.ts"; +import type { DecisionPolicy } from "./advanced.ts"; + +export interface CatalogSource { + /** File or package that declared this module, used in conflict messages. */ + source: string; + module: AuthzModule; +} + +/** Structural equality for declaration metadata. Key order is irrelevant. */ +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + const left = a as Record; + const right = b as Record; + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of keys) if (!deepEqual(left[key], right[key])) return false; + return true; +} + +/** A frozen Map that throws on mutation, so the catalog cannot drift after boot. */ +function frozenMap(entries: Iterable<[string, V]>): ReadonlyMap { + const map = new Map(entries); + const reject = () => { + throw new Error("WRN-AUTHZ-FROZEN: the authorization catalog is frozen after boot."); + }; + map.set = reject as never; + map.delete = reject as never; + map.clear = reject as never; + return map; +} + +export function emptyCatalog(): AuthzCatalog { + return { + permissions: frozenMap([]), + roles: frozenMap([]), + policies: frozenMap>([]), + attributes: frozenMap([]), + bindings: frozenMap([]), + }; +} + +export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog { + const permissions = new Map(); + const roles = new Map(); + const policies = new Map>(); + const attributes = new Map(); + const bindings = new Map>(); + const origin = new Map(); + + const claim = ( + kind: string, + key: string, + source: string, + existingValue: unknown, + value: unknown, + ) => { + const previous = origin.get(`${kind}:${key}`); + if (previous === undefined) { + origin.set(`${kind}:${key}`, source); + return; + } + if (!deepEqual(existingValue, value)) { + throw new Error( + `WRN-AUTHZ-CONFLICT: ${kind} '${key}' is declared differently in ${previous} and ${source}.`, + ); + } + }; + + for (const { source, module } of sources) { + for (const [id, meta] of Object.entries(module.permissions ?? {})) { + claim("permission", id, source, permissions.get(id), meta); + permissions.set(id, meta); + } + for (const [name, grants] of Object.entries(module.roles ?? {})) { + claim("role", name, source, roles.get(name), grants); + roles.set(name, grants); + } + for (const [name, policy] of Object.entries(module.policies ?? {})) { + // Two closures are never deep-equal, so identity is the only sane test. + const existing = policies.get(name); + if (existing && existing !== policy) { + throw new Error( + `WRN-AUTHZ-CONFLICT: policy '${name}' is declared differently in ${origin.get(`policy:${name}`)} and ${source}.`, + ); + } + origin.set(`policy:${name}`, source); + policies.set(name, policy); + } + for (const [name, meta] of Object.entries(module.attributes ?? {})) { + claim("attribute", name, source, attributes.get(name), meta); + attributes.set(name, meta); + } + for (const [permission, names] of Object.entries(module.bindings ?? {})) { + const set = bindings.get(permission) ?? new Set(); + for (const name of names) set.add(name); + bindings.set(permission, set); + } + } + + for (const [permission, names] of bindings) { + for (const name of names) { + if (!policies.has(name)) { + throw new Error( + `WRN-AUTHZ-CONFLICT: binding for '${permission}' names policy '${name}', which no module declares.`, + ); + } + } + } + + return { + permissions: frozenMap(permissions), + roles: frozenMap(roles), + policies: frozenMap(policies), + attributes: frozenMap(attributes), + bindings: frozenMap([...bindings].map(([k, v]) => [k, [...v]] as [string, readonly string[]])), + }; +} diff --git a/packages/authz/test/catalog.test.ts b/packages/authz/test/catalog.test.ts new file mode 100644 index 00000000..cc9a00db --- /dev/null +++ b/packages/authz/test/catalog.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { defineAuthz } from "../src/registry.ts"; +import { emptyCatalog, mergeCatalogs } from "../src/catalog.ts"; + +describe("mergeCatalogs", () => { + test("merges disjoint modules", () => { + const catalog = mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": {} } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "user:read": {} } }) }, + ]); + expect([...catalog.permissions.keys()].sort()).toEqual(["post:read", "user:read"]); + }); + + test("re-declaring a permission with deep-equal metadata is a no-op", () => { + const meta = { title: "View posts", risk: "low" as const }; + const catalog = mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": meta } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { ...meta } } }) }, + ]); + expect(catalog.permissions.size).toBe(1); + }); + + test("conflicting metadata is a boot error naming both files", () => { + expect(() => + mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ permissions: { "post:read": { risk: "low" } } }) }, + { source: "b.ts", module: defineAuthz({ permissions: { "post:read": { risk: "high" } } }) }, + ]), + ).toThrow(/a\.ts.*b\.ts|b\.ts.*a\.ts/s); + }); + + test("conflicting role definitions are a boot error", () => { + expect(() => + mergeCatalogs([ + { source: "a.ts", module: defineAuthz({ roles: { editor: ["post:read"] } }) }, + { source: "b.ts", module: defineAuthz({ roles: { editor: ["post:write"] } }) }, + ]), + ).toThrow(/editor/); + }); + + test("bindings for the same permission union across modules", () => { + const p1 = defineAuthz({ + permissions: { "post:write": {} }, + policies: { ownsPost: async () => ({ allowed: true }) }, + bindings: { "post:write": ["ownsPost"] }, + }); + const p2 = defineAuthz({ + policies: { notLocked: async () => ({ allowed: true }) }, + bindings: { "post:write": ["notLocked"] }, + }); + const catalog = mergeCatalogs([ + { source: "a.ts", module: p1 }, + { source: "b.ts", module: p2 }, + ]); + expect([...catalog.bindings.get("post:write")!].sort()).toEqual(["notLocked", "ownsPost"]); + }); + + test("a binding referencing a policy no module declares is a boot error", () => { + expect(() => + mergeCatalogs([ + { + source: "a.ts", + module: { permissions: { "post:write": {} }, bindings: { "post:write": ["ghost"] } }, + }, + ]), + ).toThrow(/ghost/); + }); + + test("the merged catalog is frozen", () => { + const catalog = mergeCatalogs([]); + expect(() => (catalog.permissions as Map).set("x:y", {} as never)).toThrow(); + }); + + test("emptyCatalog has no entries", () => { + expect(emptyCatalog().permissions.size).toBe(0); + }); +});