From d6a2d05407fd8240cd7950a2c72a83be2bd5fe18 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 4 Aug 2026 18:09:34 +0530 Subject: [PATCH] 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 --- ...2026-08-04-authz-permissions-implementation.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-04-authz-permissions-implementation.md b/docs/plans/2026-08-04-authz-permissions-implementation.md index e7bdf603..d6edc26f 100644 --- a/docs/plans/2026-08-04-authz-permissions-implementation.md +++ b/docs/plans/2026-08-04-authz-permissions-implementation.md @@ -1506,6 +1506,16 @@ export interface DecideInput { } 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>; decide(input: DecideInput): Promise; } @@ -1572,10 +1582,13 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise> => { const { assignments, granted } = await loadEffective(subjectId, scope); 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(); for (const entry of granted) { // 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; };