fix(authz): fix perf, doc, and fail-open gaps found in second review

Re-review of Task 6's fix round 1 (plan amendment d6a2d054) found three
items in that diff plus one adjacent pre-existing issue that C1 made
reachable:

- Important (perf): permissionsFor() rebuilt the deny Set on every
  entry in the granted set (O(grants x denies) allocations on a
  per-request path). Hoisted to build the Set once. Measured
  4000x4000: 665.92ms before, 3.90ms after.
- Important (contract accuracy): permissionsFor() only half-agrees
  with decide() — a narrow deny under a broad grant (e.g. role editor's
  "post:*" plus a deny on "post:delete") can't be represented in a flat
  Set, so the set still contains "post:*" while decide() correctly
  refuses "post:delete". Documented as NOT authoritative on the
  AuthzResolver interface, and pinned with a regression test asserting
  the divergence is deliberate.
- Minor: subject.id === "" was audited as subjectId: "" instead of
  omitted, so consoleAuditSink printed a blank subject= rather than
  subject=anonymous. Reused the same non-empty-string guard as the
  decide() path.
- Important (adjacent, advanced.ts): owner() compared subject[key] to
  resource[key] with Object.is without checking either side was
  present, so two absent ids (Object.is(undefined, undefined) ===
  true) satisfied ownership. Unreachable before this task, but C1 now
  runs bound policies for anonymous/empty subjects, putting this on a
  live path. Fixed to deny whenever either side is undefined or null.

Every fix's regression test was verified by reverting the fix and
confirming the test fails against the pre-fix code before restoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:15:34 +05:30
co-authored by Claude Opus 5
parent d6a2d05407
commit cd82bec414
4 changed files with 93 additions and 4 deletions
+10 -2
View File
@@ -39,10 +39,18 @@ export function owner<SubjectType extends Subject, Resource extends Record<strin
subjectKey: keyof SubjectType = "id",
resourceKey: keyof Resource | string = "userId",
): DecisionPolicy<SubjectType, Resource> {
return (subject, resource) =>
resource && Object.is(subject[subjectKey], resource[resourceKey as keyof Resource])
return (subject, resource) => {
const subjectValue = subject?.[subjectKey];
const resourceValue = resource?.[resourceKey as keyof Resource];
// An absent id on either side must never satisfy ownership.
if (subjectValue === undefined || subjectValue === null)
return deny("resource ownership required");
if (resourceValue === undefined || resourceValue === null)
return deny("resource ownership required");
return Object.is(subjectValue, resourceValue)
? allow("resource owner")
: deny("resource ownership required");
};
}
export function anyDecision<S, R>(...policies: DecisionPolicy<S, R>[]): DecisionPolicy<S, R> {
return async (subject, resource) => {