Bloque G1+G2 — perm preordered policies + per-decision provider memo

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
dev 5 months ago
parent 5c92a5cc7a
commit ddfc4dc6ea

@ -45,17 +45,34 @@ import type {
EvalResult,
ExprIR,
PermProviders,
PermRefRoot,
PermRequestContext,
ResourceRef,
SubjectRef
} from './types.ts';
/**
* Per-decision memoization cache. The runtime allocates one map per call
* to `evaluatePolicies` and threads it through every `evaluator.evaluate`
* invocation made for that decision, so repeated reads of the same
* attribute or relation hit the provider exactly once.
*
* Keys are stable strings derived from (kind, path, identity); values are
* the resolved provider output. Cache lives only for the duration of a
* single decision — invalidation is implicit on the next call.
*/
export type PermEvaluatorMemo = Map<string, unknown>;
export class DefaultPermEvaluator {
constructor(private readonly providers: PermProviders = {}) {}
async evaluate(expr: ExprIR, context: PermRequestContext): Promise<EvalResult> {
async evaluate(
expr: ExprIR,
context: PermRequestContext,
memo?: PermEvaluatorMemo
): Promise<EvalResult> {
try {
return await this.evaluateInternal(expr, context);
return await this.evaluateInternal(expr, context, memo);
} catch (error) {
return ok(PERM_EVAL_ERROR, [], PERM_ERROR_MSG_EVALUATION_FAILED, error);
}
@ -63,7 +80,8 @@ export class DefaultPermEvaluator {
private async rawValue(
expr: ExprIR,
context: PermRequestContext
context: PermRequestContext,
memo?: PermEvaluatorMemo
): Promise<{ value: unknown; dependencies: readonly DependencyKey[]; unknown?: string }> {
if (expr.op === PERM_EXPR_CONST) return { value: expr.value, dependencies: [] };
@ -71,11 +89,12 @@ export class DefaultPermEvaluator {
const key = expr.path ? `${expr.root}${PERM_PATH_SEPARATOR}${expr.path}` : expr.root;
const dependency = dep(expr.root, key);
if (this.providers.attributes) {
const provided = await this.providers.attributes.getAttribute({
root: expr.root,
path: expr.path,
const provided = await this.getAttributeMemoized(
memo,
expr.root,
expr.path,
context
});
);
return { value: provided, dependencies: [dependency] };
}
@ -89,7 +108,7 @@ export class DefaultPermEvaluator {
}
if (expr.op === PERM_EXPR_REL) {
const evaluated = await this.evaluateInternal(expr, context);
const evaluated = await this.evaluateInternal(expr, context, memo);
const out: { value: unknown; dependencies: readonly DependencyKey[]; unknown?: string } = {
value: evaluated.value === true,
dependencies: evaluated.dependencies
@ -100,7 +119,7 @@ export class DefaultPermEvaluator {
return out;
}
const evaluated = await this.evaluateInternal(expr, context);
const evaluated = await this.evaluateInternal(expr, context, memo);
if (evaluated.value === PERM_EVAL_UNKNOWN || evaluated.value === PERM_EVAL_ERROR) {
return {
value: undefined,
@ -111,16 +130,63 @@ export class DefaultPermEvaluator {
return { value: evaluated.value, dependencies: evaluated.dependencies };
}
/**
* Memoized attribute lookup. The cache key encodes (root, path) so
* concurrent policies that all read `actor.role` hit the provider once.
* The provider's own work is async; on a memo hit we still return a
* resolved value synchronously through the awaited promise.
*/
private async getAttributeMemoized(
memo: PermEvaluatorMemo | undefined,
root: PermRefRoot,
path: string,
context: PermRequestContext
): Promise<unknown> {
const provider = this.providers.attributes!;
if (!memo) {
return provider.getAttribute({ root, path, context });
}
const key = `attr:${root}:${path}`;
if (memo.has(key)) return memo.get(key);
const value = await provider.getAttribute({ root, path, context });
memo.set(key, value);
return value;
}
/**
* Memoized relation lookup. The cache key encodes (relation, resource
* type+id, subject type+id) so concurrent policies asking the same
* "is X member of Y" hit the provider once.
*/
private async hasRelationMemoized(
memo: PermEvaluatorMemo | undefined,
relation: string,
resource: ResourceRef,
subject: SubjectRef,
context: PermRequestContext
): Promise<boolean | typeof PERM_EVAL_UNKNOWN> {
const provider = this.providers.relations!;
if (!memo) {
return provider.hasRelation({ relation, resource, subject, context });
}
const key = `rel:${relation}:${resource.type}:${(resource as { id?: unknown }).id ?? ''}:${subject.type}:${subject.id}`;
if (memo.has(key)) return memo.get(key) as boolean | typeof PERM_EVAL_UNKNOWN;
const value = await provider.hasRelation({ relation, resource, subject, context });
memo.set(key, value);
return value;
}
private async evaluateInternal(
expr: ExprIR,
context: PermRequestContext
context: PermRequestContext,
memo?: PermEvaluatorMemo
): Promise<EvalResult> {
switch (expr.op) {
case PERM_EXPR_CONST:
return ok(Boolean(expr.value));
case PERM_EXPR_REF: {
const { value, dependencies } = await this.rawValue(expr, context);
const { value, dependencies } = await this.rawValue(expr, context, memo);
return ok(Boolean(value), dependencies);
}
@ -130,8 +196,8 @@ export class DefaultPermEvaluator {
case PERM_EXPR_GTE:
case PERM_EXPR_LT:
case PERM_EXPR_LTE: {
const left = await this.rawValue(expr.left, context);
const right = await this.rawValue(expr.right, context);
const left = await this.rawValue(expr.left, context, memo);
const right = await this.rawValue(expr.right, context, memo);
const dependencies = dedupeDeps([...left.dependencies, ...right.dependencies]);
if (left.unknown || right.unknown) {
return ok(PERM_EVAL_UNKNOWN, dependencies, left.unknown ?? right.unknown);
@ -147,8 +213,8 @@ export class DefaultPermEvaluator {
}
case PERM_EXPR_IN: {
const value = await this.rawValue(expr.value, context);
const set = await this.rawValue(expr.set, context);
const value = await this.rawValue(expr.value, context, memo);
const set = await this.rawValue(expr.set, context, memo);
const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]);
if (value.unknown || set.unknown) {
return ok(PERM_EVAL_UNKNOWN, dependencies, value.unknown ?? set.unknown);
@ -160,8 +226,8 @@ export class DefaultPermEvaluator {
}
case PERM_EXPR_CONTAINS: {
const set = await this.rawValue(expr.set, context);
const value = await this.rawValue(expr.value, context);
const set = await this.rawValue(expr.set, context, memo);
const value = await this.rawValue(expr.value, context, memo);
const dependencies = dedupeDeps([...value.dependencies, ...set.dependencies]);
if (value.unknown || set.unknown) {
return ok(PERM_EVAL_UNKNOWN, dependencies, value.unknown ?? set.unknown);
@ -179,7 +245,7 @@ export class DefaultPermEvaluator {
case PERM_EXPR_AND: {
return evaluatePermLogicalChain(
expr.args,
(arg) => this.evaluateInternal(arg, context),
(arg) => this.evaluateInternal(arg, context, memo),
{ shortCircuitValue: false, fallbackValue: true }
);
}
@ -187,13 +253,13 @@ export class DefaultPermEvaluator {
case PERM_EXPR_OR: {
return evaluatePermLogicalChain(
expr.args,
(arg) => this.evaluateInternal(arg, context),
(arg) => this.evaluateInternal(arg, context, memo),
{ shortCircuitValue: true, fallbackValue: false }
);
}
case PERM_EXPR_NOT: {
const result = await this.evaluateInternal(expr.expr, context);
const result = await this.evaluateInternal(expr.expr, context, memo);
if (result.value === true) return ok(false, result.dependencies);
if (result.value === false) return ok(true, result.dependencies);
return result;
@ -202,9 +268,9 @@ export class DefaultPermEvaluator {
case PERM_EXPR_REL: {
const relationKey = dep(PERM_DEP_RELATION, expr.path);
const resourceValue = expr.resource
? await this.rawValue(expr.resource, context)
? await this.rawValue(expr.resource, context, memo)
: { value: context.resource, dependencies: [] };
const subjectValue = await this.rawValue(expr.subject, context);
const subjectValue = await this.rawValue(expr.subject, context, memo);
const dependencies = dedupeDeps([
relationKey,
...resourceValue.dependencies,
@ -244,12 +310,13 @@ export class DefaultPermEvaluator {
);
}
const relationResult = await this.providers.relations.hasRelation({
relation: expr.path,
const relationResult = await this.hasRelationMemoized(
memo,
expr.path,
resource,
subject,
context
});
);
if (relationResult === PERM_EVAL_UNKNOWN) {
return ok(
PERM_EVAL_UNKNOWN,

@ -52,17 +52,26 @@ export function createPermRuntime(options: PermRuntimeOptions): PermRuntime {
const evaluator = new DefaultPermEvaluator(options.providers);
const defaultFallback = options.defaultFallback ?? PERM_FALLBACK_DENY;
// Preorder once at construction time. Policies are immutable for the
// lifetime of the runtime — sorting per-decision is wasted work that
// scales poorly with policy count. `combineEvaluatedPolicies` already
// assumes the entries are passed in priority order (DESC).
const orderedPolicies = [...options.policies].sort((a, b) => b.priority - a.priority);
async function evaluatePolicies(context: PermRequestContext): Promise<EvaluatedPolicy[]> {
const ordered = [...options.policies].sort((a, b) => b.priority - a.priority);
const entries: EvaluatedPolicy[] = [];
// Per-decision memo: every matching policy in this decision shares
// one cache, so repeated `actor.role` reads or `member_of(team:X)`
// checks across policies hit the providers at most once.
const memo = new Map<string, unknown>();
for (const policy of ordered) {
for (const policy of orderedPolicies) {
const matched = targetMatches(policy, context);
if (!matched) {
entries.push({ policy, targetMatched: false });
continue;
}
const evaluation = await evaluator.evaluate(policy.condition, context);
const evaluation = await evaluator.evaluate(policy.condition, context, memo);
entries.push({ policy, targetMatched: true, evaluation });
}

@ -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…
Cancel
Save

Powered by TurnKey Linux.