frozenMap only blocked the Map's own mutators, so
catalog.roles.get("editor").push("*") escalated a role to a full wildcard
past an error string claiming the catalog is frozen after boot; the same
applied to permission/attribute metadata objects and binding arrays.
mergeCatalogs now stores frozen copies of each, so the original declaring
module's objects are never mutated either.
Also corrects two docstrings (codegen.ts, the design doc) that claimed
`wrnexus authz generate`'s output makes a permission typo a type error —
can(), guardPermission(), and decideFor() all take a bare string and nothing
consumes the generated union automatically. Documents what it actually is:
a Permission/Role union to type your own helpers/constants against. Also
adds a README note on the subject.id contract (must be a non-empty string;
owner() compares with Object.is).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
4.9 KiB
TypeScript
130 lines
4.9 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);
|
|
// Freeze a COPY, not the app's own declared object: `frozenMap` only
|
|
// blocks the Map's mutators, so `catalog.permissions.get("x").risk =
|
|
// "low"` would otherwise silently rewrite metadata past a catalog that
|
|
// claims to be frozen after boot. Copying also avoids freezing (and
|
|
// thus permanently locking) an object the declaring module might still
|
|
// hold a live reference to.
|
|
permissions.set(id, Object.freeze({ ...meta }));
|
|
}
|
|
for (const [name, grants] of Object.entries(module.roles ?? {})) {
|
|
claim("role", name, source, roles.get(name), grants);
|
|
// Same reasoning: without this, `catalog.roles.get("editor").push("*")`
|
|
// succeeds and silently escalates a role to a full wildcard.
|
|
roles.set(name, Object.freeze([...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, Object.freeze({ ...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, Object.freeze([...v])] as [string, readonly string[]]),
|
|
),
|
|
};
|
|
}
|