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