Files
WRNexusJS/packages/authz/src/catalog.ts
T

120 lines
4.2 KiB
TypeScript

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<string, unknown>;
const right = b as Record<string, unknown>;
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<V>(entries: Iterable<[string, V]>): ReadonlyMap<string, V> {
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<PermissionMeta>([]),
roles: frozenMap<readonly string[]>([]),
policies: frozenMap<DecisionPolicy<never, never>>([]),
attributes: frozenMap<AttributeMeta>([]),
bindings: frozenMap<readonly string[]>([]),
};
}
export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
const permissions = new Map<string, PermissionMeta>();
const roles = new Map<string, readonly string[]>();
const policies = new Map<string, DecisionPolicy<never, never>>();
const attributes = new Map<string, AttributeMeta>();
const bindings = new Map<string, Set<string>>();
const origin = new Map<string, string>();
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<string>();
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[]])),
};
}