docs: warn against the permissionMatches + permissionsFor composition

permissionsFor carries a caveat that its Set cannot represent a narrow deny
under a broad grant, so callers must gate with decide(). Now that
permissionMatches is also public, the wrong composition is directly reachable
and looks idiomatic - and the warning lived only on the other half of it.
Adds the pointer to permissionMatches, and covers scopeKey and safeRecord in
the exports test, which the brief omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 19:36:24 +05:30
co-authored by Claude Opus 5
parent 6f3a53b9ff
commit e05ddc7aa5
@@ -1536,7 +1536,13 @@ export function expandRoles(catalog: AuthzCatalog, roles: readonly string[]): Se
return out; return out;
} }
/** Exact match, root wildcard, or a namespace wildcard at any depth. */ /**
* Exact match, root wildcard, or a namespace wildcard at any depth.
*
* Do NOT gate access by matching against `permissionsFor()`'s result — that set
* cannot represent a narrow deny beneath a broad grant, so the composition
* returns true where `decide()` refuses. Use `decide()` / `can()` instead.
*/
export function permissionMatches(granted: Set<string>, permission: string): boolean { export function permissionMatches(granted: Set<string>, permission: string): boolean {
if (granted.has("*") || granted.has(permission)) return true; if (granted.has("*") || granted.has(permission)) return true;
for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) { for (let at = permission.indexOf(":"); at !== -1; at = permission.indexOf(":", at + 1)) {
@@ -2408,6 +2414,8 @@ describe("@wrnexus/authz exports", () => {
"createAuthzResolver", "createAuthzResolver",
"expandRoles", "expandRoles",
"permissionMatches", "permissionMatches",
"scopeKey",
"safeRecord",
"deniedBy", "deniedBy",
"authzMiddleware", "authzMiddleware",
"can", "can",