Skip to main content

Evaluation pipeline

End-to-end trace of an authorization check - subject resolution, scoped-role enrichment, policy-set assembly, per-policy evaluation, and the decision object

This page follows one check from the call site to the returned decision. Every step maps to a named function in src/core/engine, src/core/rbac, and src/core/evaluate, and each is described exactly as it is implemented. Read rule matching for what happens inside a single rule and cross-policy combination for the final merge.

Entry points

Five methods start a check. All of them converge on authorize().

MethodInputReturnsNotes
can(subjectId, action, resource, environment?, scope?)subject idPromise<boolean>Always a boolean, in both modes. Catches subject-resolution errors and fails closed.
check(subjectId, action, resource, environment?, scope?)subject idPromise<ModeResult<TMode>>IDecision in development, boolean in production.
authorize(request)a full IamRequest.IAccessRequestPromise<ModeResult<TMode>>The pipeline proper. Use when you already hold a resolved subject.
permissions(subjectId, checks, environment?)subject id + up to 1024 checksPromise<ModePermissionMap<TMode>>Loads adapter data once, then evaluates each check through the same path.
explain(subjectId, action, resource, environment?, scope?)subject idPromise<Explain.IResult>development only; throws in production. Read-only.

can and check reject a subjectId that is not a non-empty string of at most 1024 characters - can returns false, check returns a synthesised deny, and explain throws. permissions throws on an invalid subject id or on more than 1024 checks, because an oversized batch is a caller bug rather than a request to deny.

The pipeline

One engine.can() call, from the caller to the decision.

Loading diagram...

Every adapter arrow above is cache-fronted and single-flighted: a cold start under load issues one adapter call per resource, not one per request. The steps below unpack each stage.

Step 1 - subject resolution

resolveSubject() turns a subjectId into an IamRequest.ISubject. The order matters, and it is fixed.

Loading diagram...

The steps, in order:

  1. Cache. subjectCache is an LRU sized by maxCacheSize (default 1000) with a TTL of cacheTTL seconds (default 60). A hit returns immediately.
  2. Single flight. Concurrent misses for the same subject id share one promise. An invalidation that lands mid-flight prevents the late resolver from writing stale data back into the cache.
  3. Load shedding. When maxConcurrentSubjectLoads distinct, never-before-cached subject loads are already in flight, a new load throws (subject load shed: N concurrent subject loads already in flight (cap M)) rather than joining an unbounded herd. The default is 512; 0 restores unbounded. A cache hit or a join onto an existing load never counts against the cap.
  4. Three parallel reads. adapter.getSubjectRoles(subjectId), adapter.getSubjectAttributes(subjectId), and the cached listRoles(). Each adapter call runs under adapterTimeoutMs (default 5000) with a real AbortSignal; a timeout throws and the entry point fails closed.
  5. Inheritance closure. resolveEffectiveRoles(assignedRoles, allRoles) walks each assigned role's inherits chain. Cycles are cut by a shallowest-depth memo, depth is bounded by MAX_INHERITANCE_DEPTH (32), and roles past that depth do not enter subject.roles - though can() may still grant their permissions, because the bound is on traversal and not on the grant. See role inheritance. The result is deduplicated and keeps directly assigned ids even when the catalog does not define them; a dangling inherited id is dropped.
  6. Scoped roles. Only when the adapter implements the optional getSubjectScopedRoles. Each scoped assignment is closed over inherits too, so a check at an inherited-into scope can see it. The directly assigned role keeps the scope it was assigned at; every other role produced by that closure is retagged with its own IRole.scope, falling back to the assignment row's scope when the role declares none. This matches how rolesToPolicy gates each role's rules.
  7. Assemble and cache. { id, roles, scopedRoles, attributes }.

engine.getEffectiveRoles(subjectId, scope?) exposes the result of this step plus the enrichment below, and shares the same cache.

Step 2 - scoped-role enrichment

authorize() does two normalisations before anything is evaluated.

First, a subject.roles that is not an array is replaced with []. A bare string would substring-match the contains condition that rolesToPolicy generates.

Second, when the request carries a scope and the subject has scoped roles, enrichSubjectWithScopedRoles() merges the matching grants into subject.roles. It returns the original object unchanged when nothing matches, so the common case allocates nothing.

Loading diagram...

OptionValuesDefaultMeaning
scopeMode'flat', 'hierarchical''flat''flat' requires an exact scope match. 'hierarchical' treats a dotted scope as a path, so a grant at org-1 applies to org-1.team-2.repo-3. Safe to enable for undotted scopes, which degrade to exact match.
scopeCombine'union', 'override''union'Only read under 'hierarchical'. 'union' ORs in every matching level; 'override' applies only the most specific matching level, so a narrower grant shadows a broader one instead of adding to it.

scopeAncestors('org-1.team-2.repo-3') yields ['org-1.team-2.repo-3', 'org-1.team-2', 'org-1']. Hierarchical union is additive only: there is no per-level revoke.

Step 3 - hooks and the evaluation clock

beforeEvaluate runs next, and it can rewrite the request - this is the supported place to inject environment fields, override a subject attribute, or pin a clock for a replay.

ensureEnvNow() runs after the hook. It sets environment.now to Date.now() only when it is still absent, so a hook-pinned or test-pinned now always wins. Temporal operators and $environment.now references depend on this field existing.

Step 4 - policy-set assembly

loadAllPolicies() produces the array the evaluator walks.

  1. listPolicies() from the adapter, cached under a single-entry LRU with the cacheTTL TTL. An adapter returning more than maxPolicies (default 10000) rows throws rather than silently truncating.
  2. listRoles() the same way, capped by maxRoles (default 10000).
  3. rolesToPolicy(roles) builds the synthetic policy, which is then deep-frozen and cached.
  4. The merged array is [__rbac__, ...adapterPolicies] - unless the generated policy has zero rules, in which case it is omitted entirely so it cannot participate in the combine.

What rolesToPolicy emits, for every permission of every role after its own inheritance closure:

{
  id: '__rbac__#0',                 // monotonic counter, stable across identical input
  effect: 'allow',                  // role permissions are allow-only
  description: 'Editor: update on post',
  priority: 10,
  actions: ['update'],
  resources: ['post'],
  conditions: {
    all: [
      { field: 'subject.roles', operator: 'contains', value: 'editor' },
      // present only when the DECLARING role or the permission sets a scope
      // that is neither undefined nor '*'
      { field: 'scope', operator: 'eq', value: 'org-1' },
    ],
  },
}

The wrapping policy is { id: '__rbac__', name: 'RBAC Policies', algorithm: 'allow-overrides', rules }. A permission that carries its own conditions gets { all: [{ all: [...base] }, perm.conditions] } instead - the author's group is nested whole rather than spliced, so an unrecognised group key fails closed and the group always sits at the same depth whatever its key is. Rule ids use the __rbac__#N counter rather than interpolated names, because a role, action, or resource containing a dot used to produce ambiguous ids.

Under scopeMode: 'hierarchical' the single scope eq becomes { any: [scope eq S, scope starts_with "S."] }, so a role-declared scope covers its descendants the way an assignment scope does.

Step 5 - per-policy evaluation

Each policy is evaluated independently by evaluatePolicy(), which produces exactly one of three outcomes: allow, deny, or NotApplicable.

  1. policyApplies() checks targets.actions (with matchesAction), targets.resources (with matchesResource), and targets.roles (exact membership in subject.roles). Any declared dimension that fails makes the policy NotApplicable.
  2. If no rule in the policy shape-matches the request's action and resource (ruleTargetsMatch), the policy is NotApplicable too. A policy about update has nothing to say about read and must not cast a default-effect vote just because its targets were silent.
  3. Otherwise every rule is tested with ruleApplies() - shape match plus the full condition group - and the matches are folded by combiners[policy.algorithm].

The combiner returns the winning rule, the effect, and a reason string; evaluatePolicy wraps that into an IDecision. See rule matching for the shape and condition gates, and combining algorithms for the fold.

Step 6 - cross-policy combination

evaluate() merges the per-policy decisions according to policyCombine, skipping every decision marked applicable: false. The default is 'and': every applicable policy must allow, and the first non-allow short-circuits and is returned as the final decision. A policy that throws is reported through the onPolicyError hook and treated as NotApplicable rather than failing the whole request.

The full semantics of all three modes, including the fall-through reasons and what changes in production, are on cross-policy combination.

Step 7 - the decision

In development mode the pipeline returns an AccessControl.IDecision:

FieldMeaning
allowedThe verdict as a boolean.
effect'allow' or 'deny'.
ruleThe IRule that decided, when one did.
policyThe id of the deciding policy, e.g. __rbac__.
reasonText such as Denied by rule "block-banned" or No policy applicable. Defaulted to deny.
durationMilliseconds measured across the whole cross-policy walk.
timestampDate.now() at the decision.
applicableOnly ever false, only on a per-policy NotApplicable result. Absent on the final decision returned to you.

In production mode the pipeline returns a plain boolean - no decision object is allocated.

import { IamEngine } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'

// mode defaults to 'production', which returns a bare boolean
const engine = new IamEngine({ adapter: new IamMemoryAdapter(), mode: 'development' })

const decision = await engine.check('user-1', 'update', {
  type: 'post',
  id: 'post-42',
  attributes: { ownerId: 'user-1', status: 'draft' },
})

if (typeof decision !== 'boolean' && !decision.allowed) {
  console.warn(decision.reason, decision.policy, decision.rule?.id)
}

Development versus production

mode defaults to 'production'. It does not select the evaluator: both modes get their verdict from the compiled table. What the modes differ in is whether a second evaluator runs alongside it, and how much provenance survives.

developmentproduction (default)
Verdict fromthe compiled tablethe compiled table
Interpreter also runsyes, for the explanationno
Return of authorize / checkAccessControl.IDecisionboolean
Return of canbooleanboolean
explain()worksthrows
decision.policy / decision.rulepresentundefined
decision.reasonthe interpreter's real reason'Allowed/Denied (production mode; compiled table does not retain policy identity)'
Table-versus-interpreter cross-checkonoff
policyCombine: 'first-applicable'supported, at the cost of the compiled tableconstructor throws

Loading diagram...

The table is built from the roles and the raw adapter policies; only policies it could not flatten - targeted ones, and ones with non-literal action or resource patterns - fall back to evaluatePolicyFast() per request. It is rebuilt lazily on the first authorize() after any policy or role invalidation, under a generation counter so a build that started before an invalidation cannot overwrite a newer one.

The development-only explanatory run is passed onPolicyError: undefined and its own signals bag, so a handler wired to an alerting pipeline is not paged twice for one bad policy and a failOpen seen only by the explanatory run cannot rewrite what the authoritative path reported. Once the two verdicts agree the signals take the union.

Hooks and error semantics

HookWhenCan it change the decision?
beforeEvaluate(request)before the clock is defaulted and policies are loadedyes - it returns the request that gets evaluated
onPolicyError(err, policyId)a single policy threwno - that policy is skipped as NotApplicable
afterEvaluate(request, decision)after the decision is finalno
onDeny(request, decision)after a deny decision is finalno
onError(err, request)the whole evaluation threwno - the engine has already failed closed
onMetrics(event)last, alwaysno

The trailing hooks run outside the evaluation try, and each is individually wrapped, so a throwing hook cannot rewrite an allow into a deny or suppress its siblings. A throw anywhere in the evaluation itself produces a fail-closed result: false in production, or a deny decision with reason Evaluation error.

explain() deliberately fires only beforeEvaluate - it applies, because it changes what is evaluated - and skips afterEvaluate, onDeny, and onMetrics. Tracing a request must not emit audit events.

Caching

Five caches sit in front of the adapter. Four of them hold a single entry; only the subject cache is sized.

CacheKeyEntriesTTL
policies'all'1cacheTTL (default 60s)
roles'all'1cacheTTL
rbacPolicy'rbac'1cacheTTL
mergedPolicies'merged'1cacheTTL
subjectssubjectIdmaxCacheSize (default 1000)cacheTTL

engine.admin.* writes invalidate the right caches automatically. Manual control is on the engine.cache facet: invalidate(), invalidatePolicies(), invalidateRoles(roleId?), invalidateSubject(subjectId), each taking an optional { broadcast?: boolean }. engine.stats.get() reports hits, misses, and size per cache.

In a multi-instance deployment, wire an invalidator so every node drops its caches when any node writes. Without one, a node can serve a decision from a stale policy set for up to cacheTTL. See caching and the Redis invalidator.

Tracing with explain()

When you need to know exactly why a check decided as it did, swap can() for explain().

const trace = await engine.explain('user-1', 'update', {
  type: 'post',
  attributes: { ownerId: 'user-1' },
})

console.log(trace.summary)
console.log(trace.policies) // per-policy breakdown
console.log(trace.rules)    // per-rule match detail

The trace also reports which roles came from scoped enrichment: explain() records the subject's roles before enrichment and the ones the request's scope added. Explain.IResult is documented field by field on explain and debug.

API reference

The evaluator is exported from @gentleduck/iam and @gentleduck/iam/core for callers who want to run it without an engine.

function evaluate(
  policies: AccessControl.IPolicy[],
  request: IamRequest.IAccessRequest,
  defaultEffect?: AccessControl.Effect,      // default 'deny'
  combine?: AccessControl.PolicyCombine,     // default 'and'
  onPolicyError?: (err: Error, policy: AccessControl.IPolicy) => void,
  signals?: { failOpen?: boolean },
  caches?: { regex?: Map<string, RegExp>; path?: Map<string, string[] | null> },
): AccessControl.IDecision

function evaluatePolicy(
  policy: AccessControl.IPolicy,
  request: IamRequest.IAccessRequest,
  defaultEffect?: AccessControl.Effect,
  caches?: { regex?: Map<string, RegExp>; path?: Map<string, string[] | null> },
): AccessControl.IDecision

function evaluateFast(/* same parameters as evaluate */): boolean

function evaluatePolicyFast(/* same parameters as evaluatePolicy */): boolean | null

function indexPolicy(policy: AccessControl.IPolicy): Evaluate.IPolicyRuleIndex
ExportReturnsNotes
evaluateIDecisionReference implementation. Never throws for a bad policy; routes it to onPolicyError.
evaluatePolicyIDecisionOne policy. applicable: false marks NotApplicable.
evaluateFastbooleanAllocation-light multi-policy walk. Treats 'first-applicable' as 'and'.
evaluatePolicyFastboolean | nullnull means NotApplicable.
indexPolicyEvaluate.IPolicyRuleIndexBuilds (and WeakMap-caches) the rule index the fast path uses.

The optional signals parameter is an out-parameter, not a returned value. The evaluator sets signals.failOpen = true when - and only when - the result is an allow produced by the defaultEffect fallback with no applicable policy. Chart it: it is the signal that a policy set has silently gone missing.

Evaluate is the namespace holding the index types:

TypeWhat it is
Evaluate.CombinerSignature of a combining-algorithm implementation.
Evaluate.IIndexedRuleA rule plus its action and resource pattern sets and precomputed wildcard flags.
Evaluate.IPolicyRuleIndexThe four buckets (byActionResource, byActionWildcardResource, byResourceWildcardAction, wildcardBoth) plus the precomputed result map.

The index keys literal rules by a NUL-joined action and resource pair. A rule with an expansive pattern on one side is bucketed by whichever side is still literal, so a request only scans the rules whose literal side already matches. Rules that are expansive on both sides stay a linear scan. When a policy has no expansive patterns at all and its algorithm is deny-overrides, allow-overrides, or first-match, unconditional rules are folded into precomputed, giving an O(1) answer with no rule scan.

Gotchas

  • can() swallows resolution errors, check() does not swallow them silently. Both fail closed, but check() returns a decision whose reason is Subject resolution error so you can distinguish it from a real deny.
  • The subject cache holds resolved roles. Assigning a role does not take effect on other engine instances until their cache TTL expires or an invalidation reaches them.
  • explain() is not free and not available in production. It loads its own module chunk lazily so production bundles pay nothing for it.
  • permissions() enriches per scope. Each check in the batch goes through scoped-role enrichment for its own scope, with the enriched subject memoised per scope for the batch.
  • environment.now is defaulted after beforeEvaluate. Pin it in the hook, not before the call, if you need a deterministic clock.
  • A rule with a non-finite priority ranks as 0. It does not vanish from first-match and highest-priority comparisons, and it does not throw.

See also