diff --git a/src/libs/perm/evaluator.ts b/src/libs/perm/evaluator.ts index e17d5f3..cc3ad79 100644 --- a/src/libs/perm/evaluator.ts +++ b/src/libs/perm/evaluator.ts @@ -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; + export class DefaultPermEvaluator { constructor(private readonly providers: PermProviders = {}) {} - async evaluate(expr: ExprIR, context: PermRequestContext): Promise { + async evaluate( + expr: ExprIR, + context: PermRequestContext, + memo?: PermEvaluatorMemo + ): Promise { 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 { + 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 { + 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 { 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, diff --git a/src/libs/perm/runtime.ts b/src/libs/perm/runtime.ts index 29b8998..bcb0a73 100644 --- a/src/libs/perm/runtime.ts +++ b/src/libs/perm/runtime.ts @@ -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 { - 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(); - 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 }); } diff --git a/src/libs/perm/test/memoize.test.ts b/src/libs/perm/test/memoize.test.ts new file mode 100644 index 0000000..7dd674e --- /dev/null +++ b/src/libs/perm/test/memoize.test.ts @@ -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); + }); +});