fix(authz): freeze catalog values after boot; correct compile-time-check claims

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>
This commit is contained in:
2026-08-05 02:10:24 +05:30
co-authored by Claude Opus 5
parent 3867e7c183
commit 41b6e2ed2b
5 changed files with 106 additions and 8 deletions
+14 -4
View File
@@ -71,11 +71,19 @@ export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
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);
// 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);
roles.set(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.
@@ -90,7 +98,7 @@ export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
}
for (const [name, meta] of Object.entries(module.attributes ?? {})) {
claim("attribute", name, source, attributes.get(name), meta);
attributes.set(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>();
@@ -114,6 +122,8 @@ export function mergeCatalogs(sources: CatalogSource[]): AuthzCatalog {
roles: frozenMap(roles),
policies: frozenMap(policies),
attributes: frozenMap(attributes),
bindings: frozenMap([...bindings].map(([k, v]) => [k, [...v]] as [string, readonly string[]])),
bindings: frozenMap(
[...bindings].map(([k, v]) => [k, Object.freeze([...v])] as [string, readonly string[]]),
),
};
}