docs: hoist the deny set and document permissionsFor's limits

Two issues the Task 6 re-review raised against the fix diff.

permissionsFor rebuilt the deny Set inside its loop over granted entries,
making it O(grants x denies) allocations on a per-request path. Measured
632ms at 4000x4000, ~100% of it in repeated Set construction. Hoisted.

permissionsFor also only half-delivers on "the obvious composition agrees
with decide()". A narrow deny beneath a broad grant is not representable in
a Set of strings - the set keeps post:* while decide() correctly refuses
post:delete - so callers that match against the set would offer actions the
server rejects. Documented the limit on the interface and pointed callers at
decide()/can()/filterCan() for per-action gating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:09:34 +05:30
co-authored by Claude Opus 5
parent ae37c9b57a
commit d6a2d05407
@@ -1506,6 +1506,16 @@ export interface DecideInput {
} }
export interface AuthzResolver { export interface AuthzResolver {
/**
* Effective permissions with denied entries removed — for coarse gating such
* as hiding a menu section.
*
* NOT authoritative. A set of strings cannot express "everything under
* `post:*` except `post:delete`", so a narrow deny beneath a broad grant is
* not representable here: the set still contains `post:*` while `decide()`
* correctly refuses `post:delete`. Gate individual actions with `decide()`
* (or `can()` / `filterCan()`), never by matching against this set.
*/
permissionsFor(subjectId: string, scope?: AuthzScope): Promise<Set<string>>; permissionsFor(subjectId: string, scope?: AuthzScope): Promise<Set<string>>;
decide(input: DecideInput): Promise<AuthorizationDecision>; decide(input: DecideInput): Promise<AuthorizationDecision>;
} }
@@ -1572,10 +1582,13 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve
const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise<Set<string>> => { const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise<Set<string>> => {
const { assignments, granted } = await loadEffective(subjectId, scope); const { assignments, granted } = await loadEffective(subjectId, scope);
if (!assignments.denies.length) return granted; if (!assignments.denies.length) return granted;
// Hoist the deny set: rebuilding it per entry makes this O(grants x denies)
// allocations on a per-request path whose input size an operator controls.
const denySet = new Set(assignments.denies);
const effective = new Set<string>(); const effective = new Set<string>();
for (const entry of granted) { for (const entry of granted) {
// A wildcard grant survives only if nothing denies it outright. // A wildcard grant survives only if nothing denies it outright.
if (!deniedBy(assignments.denies, entry)) effective.add(entry); if (!permissionMatches(denySet, entry)) effective.add(entry);
} }
return effective; return effective;
}; };