G1 — `createPermRuntime` sorts policies by priority once at construction instead of every decision. Policies are immutable for the runtime's lifetime; per-decision sorting was wasted work that scaled poorly with policy count. `combineEvaluatedPolicies` already assumed entries arrive in priority order, so the change is behavior-preserving. G2 — `DefaultPermEvaluator.evaluate(expr, context, memo?)` accepts an optional `PermEvaluatorMemo` (Map<string, unknown>) and threads it through every internal recursion. The runtime allocates one fresh memo per `evaluatePolicies` call, so concurrent matching policies asking for the same `actor.role` attribute or the same `member_of(team)` relation hit the providers exactly once per decision. Adjacent decisions get fresh memos — stale data never leaks across requests. Test covers (a) attribute provider called once across N policies in one decision, (b) relation provider called once across N policies in one decision, (c) two adjacent decisions allocate two memos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>master
parent
5c92a5cc7a
commit
ddfc4dc6ea
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Bloque G2 — every decision allocates a fresh memo and shares it across
|
||||
* matching policies, so attribute and relation providers are called at
|
||||
* most once per (root, path) and (relation, resource, subject) within a
|
||||
* single `check()`/`explain()` call.
|
||||
*
|
||||
* Two adjacent decisions allocate two memos, so a stale value never
|
||||
* leaks across requests.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createPermRuntime } from '../runtime.ts';
|
||||
import {
|
||||
PERM_EFFECT_ALLOW,
|
||||
PERM_EXPR_CONST,
|
||||
PERM_EXPR_EQ,
|
||||
PERM_EXPR_REF,
|
||||
PERM_EXPR_REL,
|
||||
PERM_ROOT_ACTOR
|
||||
} from '../consts.ts';
|
||||
import type { PolicyIR, PermSchema, SubjectRef } from '../types.ts';
|
||||
|
||||
const baseSchema: PermSchema = {
|
||||
tenants: false,
|
||||
resources: {},
|
||||
attributes: {}
|
||||
};
|
||||
|
||||
describe('perm runtime — per-decision memoization', () => {
|
||||
it('calls the attribute provider once even when many policies read the same path', async () => {
|
||||
const getAttribute = vi.fn(async () => 'admin');
|
||||
|
||||
const policies: PolicyIR[] = [1, 2, 3, 4].map((n) => ({
|
||||
id: `p-${n}`,
|
||||
effect: PERM_EFFECT_ALLOW,
|
||||
priority: n,
|
||||
target: { action: 'read', resource: 'doc' },
|
||||
condition: {
|
||||
op: PERM_EXPR_EQ,
|
||||
left: { op: PERM_EXPR_REF, root: PERM_ROOT_ACTOR, path: 'role' },
|
||||
right: { op: PERM_EXPR_CONST, value: 'admin' }
|
||||
}
|
||||
}));
|
||||
|
||||
const runtime = createPermRuntime({
|
||||
schema: baseSchema,
|
||||
policies,
|
||||
providers: { attributes: { getAttribute } }
|
||||
});
|
||||
|
||||
const actor: SubjectRef = { type: 'user', id: 'u-1' };
|
||||
await runtime.check({ actor, action: 'read', resource: { type: 'doc', id: 'd-1' } });
|
||||
|
||||
expect(getAttribute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls the relation provider once even when many policies share the same relation', async () => {
|
||||
const hasRelation = vi.fn(async () => true);
|
||||
|
||||
const policies: PolicyIR[] = [1, 2, 3].map((n) => ({
|
||||
id: `rel-${n}`,
|
||||
effect: PERM_EFFECT_ALLOW,
|
||||
priority: n,
|
||||
target: { action: 'read', resource: 'doc' },
|
||||
condition: {
|
||||
op: PERM_EXPR_REL,
|
||||
path: 'member_of',
|
||||
subject: { op: PERM_EXPR_REF, root: PERM_ROOT_ACTOR, path: '' }
|
||||
}
|
||||
}));
|
||||
|
||||
const runtime = createPermRuntime({
|
||||
schema: baseSchema,
|
||||
policies,
|
||||
providers: { relations: { hasRelation } }
|
||||
});
|
||||
|
||||
const actor: SubjectRef = { type: 'user', id: 'u-1' };
|
||||
await runtime.check({ actor, action: 'read', resource: { type: 'doc', id: 'd-1' } });
|
||||
|
||||
expect(hasRelation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not leak the memo across two independent decisions', async () => {
|
||||
let counter = 0;
|
||||
const getAttribute = vi.fn(async () => `attr-${++counter}`);
|
||||
|
||||
const policy: PolicyIR = {
|
||||
id: 'p',
|
||||
effect: PERM_EFFECT_ALLOW,
|
||||
priority: 0,
|
||||
target: { action: 'read', resource: 'doc' },
|
||||
condition: {
|
||||
op: PERM_EXPR_EQ,
|
||||
left: { op: PERM_EXPR_REF, root: PERM_ROOT_ACTOR, path: 'role' },
|
||||
right: { op: PERM_EXPR_CONST, value: 'admin' }
|
||||
}
|
||||
};
|
||||
|
||||
const runtime = createPermRuntime({
|
||||
schema: baseSchema,
|
||||
policies: [policy],
|
||||
providers: { attributes: { getAttribute } }
|
||||
});
|
||||
|
||||
const actor: SubjectRef = { type: 'user', id: 'u-1' };
|
||||
|
||||
await runtime.check({ actor, action: 'read', resource: { type: 'doc', id: 'd-1' } });
|
||||
await runtime.check({ actor, action: 'read', resource: { type: 'doc', id: 'd-2' } });
|
||||
|
||||
// Two decisions → two provider calls (one each), not one shared.
|
||||
expect(getAttribute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue