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; };