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:
@@ -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) => {
|
||||
|
||||
@@ -24,6 +24,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<Set<string>>;
|
||||
decide(input: DecideInput): Promise<AuthorizationDecision>;
|
||||
}
|
||||
@@ -85,17 +95,21 @@ export function createAuthzResolver(options: AuthzResolverOptions): AuthzResolve
|
||||
const permissionsFor = async (subjectId: string, scope?: AuthzScope): Promise<Set<string>> => {
|
||||
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<string>();
|
||||
for (const entry of granted) {
|
||||
if (!deniedBy(assignments.denies, entry)) effective.add(entry);
|
||||
if (!permissionMatches(denySet, entry)) effective.add(entry);
|
||||
}
|
||||
return effective;
|
||||
};
|
||||
|
||||
const finish = (input: DecideInput, result: AuthorizationDecision): AuthorizationDecision => {
|
||||
if (!result.allowed || options.auditAllows) {
|
||||
const rawId = input.subject?.id;
|
||||
safeRecord(audit, {
|
||||
subjectId: typeof input.subject?.id === "string" ? input.subject.id : undefined,
|
||||
subjectId: typeof rawId === "string" && rawId !== "" ? rawId : undefined,
|
||||
scope: input.scope,
|
||||
permission: input.permission,
|
||||
allowed: result.allowed,
|
||||
|
||||
Reference in New Issue
Block a user