Skip to main content

Engine overview

IamEngine - the runtime that resolves subjects, loads policies, evaluates decisions and caches everything in between

IamEngine is the runtime that answers "may this subject do this action on this resource?". You construct one with an adapter, it loads roles and policies through that adapter, caches them, resolves the subject, runs the evaluation pipeline, and returns either an AccessControl.IDecision or a plain boolean depending on its mode. This page covers construction, configuration, the decision shape, and the object graph; the sibling pages cover each surface in depth.

The object graph

The engine is one class with three facets hanging off it. Everything else is internal.

Loading diagram...

EVAL and LIFE stay flat on the instance because they are the hot path. CACHEF, STATSF and ADMINF are grouped nouns: engine.cache.*, engine.stats.*, engine.admin.*. LOAD is the five-cache loader layer described on caching; AD is whichever adapter you passed in.

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

const adapter = new IamMemoryAdapter({
  roles: [viewer, editor],
  assignments: { 'user-1': ['editor'] },
})

const engine = new IamEngine({ adapter, defaultEffect: 'deny' })

Where a request goes

One engine.can() call goes through these stages.

Loading diagram...

The subjectId guard at B rejects anything that is not a string of 1 to 1024 characters. C is the subject cache plus single-flight coalescing. E exists because a string subject.roles would substring-match a contains condition. F merges scoped role grants for the request's scope. G runs your beforeEvaluate hook, and H deliberately runs after it so a hook-pinned environment.now survives.

I is the load-bearing step: the compiled table produces the verdict in both modes. Development additionally runs the interpreter, because the table cannot explain itself — a CONST_ALLOW cell is one byte and policy identity is erased at compile time. That second run is what fills in decision.policy and decision.rule, and a disagreement between the two throws rather than answering. M fires in both modes; production synthesises a verdict-only decision for afterEvaluate and onDeny, and only when one of them is wired.

Reading order

PageCovers
Engine methodsauthorize, can, check, getEffectiveRoles, permissions, explain, preload, healthCheck, setInvalidator, withTransaction, dispose, stats
Caching and invalidationThe five LRU caches, every key, single-flight, every invalidation trigger and its ordering
HooksThe seven hooks, their arguments, firing order, and error semantics
Development vs production modeWhat each mode returns, which hooks fire, which options are refused
Compiled tableProduction's data structure: role bitmasks, the 32-role cap, residual policies
Admin APIengine.admin.* CRUD, input validation, snapshot export and import

API reference

Constructor

new IamEngine<TAction, TResource, TRole, TScope, TMode>(
  config: IamEngineTypes.IConfig<TAction, TResource, TRole, TScope, TMode>,
)

The five type parameters narrow the API: TAction and TResource constrain what you may pass to can/check, TRole constrains admin.assignRole, TScope constrains every scope argument, and TMode decides statically whether the evaluation methods return AccessControl.IDecision or boolean. The four string parameters default to string; TMode defaults to 'production'.

TMode is a type argument. IConfig.mode is optional, so writing new IamEngine<A, R, Ro, S, 'development'>({ adapter }) types check() as returning IDecision while the engine still runs in production and returns a boolean — reading .allowed off it yields undefined, which is falsy, so the mismatch shows up as assertions that quietly pass. Always pass mode explicitly.

A factory is exported for callers who prefer functions to new:

import { iamEngine } from '@gentleduck/iam'

const engine = iamEngine({ adapter })

iamEngine forwards its arguments to the constructor unchanged and returns an untyped IamEngine (all type parameters at their defaults), so reach for new IamEngine<...>() when you want typed actions or a typed mode.

IamEngineTypes.IConfig

OptionTypeDefaultMeaning
adapterIamAdapter.IAdapterrequiredStorage backend for policies, roles, assignments and attributes
defaultEffect'allow' | 'deny''deny'The vote cast when a source is applicable but no rule fired
cacheTTLnumber60Cache entry lifetime in seconds; multiplied by 1000 internally
maxCacheSizenumber1000Maximum entries in the subject cache only
mode'development' | 'production''production'Return shape, and whether the interpreter runs alongside the compiled table
hooksIamEngineTypes.IHooks{}Lifecycle hooks
policyCombine'and' | 'allow-overrides' | 'first-applicable''and'How verdicts from separate policies merge
maxPoliciesnumber10000Hard ceiling on policies read from the adapter
maxRolesnumber10000Hard ceiling on roles read from the adapter
allowFailOpenbooleanfalseRequired acknowledgement for defaultEffect: 'allow'
adapterTimeoutMsnumber5000Per-adapter-call timeout; 0 disables
maxConcurrentSubjectLoadsnumber512Cap on concurrent distinct-subject loads; 0 restores unbounded
invalidatorIamEngineTypes.IInvalidatornoneCross-instance cache-invalidation broadcaster
scopeMode'flat' | 'hierarchical''flat'Whether a scoped grant matches only its exact scope or every descendant
scopeCombine'union' | 'override''union'How multiple matching ancestor levels combine under 'hierarchical'

Notes that are easy to get wrong:

  • cacheTTL is in seconds; adapterTimeoutMs is in milliseconds.
  • maxCacheSize sizes the subject cache. The other four caches are hardcoded to a single entry, because each holds one whole collection.
  • cacheTTL: 0 does not disable caching cleanly. Every LRU entry expires the instant it is written and the compiled table is rebuilt on every request, so each check re-reads listRoles and listPolicies and re-runs compileTable. Correct, and very slow. Tests only.
  • maxConcurrentSubjectLoads bounds concurrency, not totals. A call that hits the subject cache, or joins an in-flight load for the same subject, never counts against it. Shedding surfaces as a fail-closed false plus onError, not as a throw at the call site.

Scope options

scopeMode and scopeCombine govern enrichSubjectWithScopedRoles, which merges subject.scopedRoles into subject.roles before evaluation.

scopeModeMatch rule
'flat'scopedRole.scope === request.scope, exact string equality
'hierarchical'The request scope is split on . into itself plus every ancestor prefix; a grant at any of those levels matches

Under 'hierarchical', a request scope of 'org-1.team-2.repo-3' walks ['org-1.team-2.repo-3', 'org-1.team-2', 'org-1'], most specific first. scopeCombine: 'union' (the default) merges the roles from every matching level; 'override' stops at the first level that has any grant, so a narrower grant shadows a broader one rather than adding to it. Under 'flat' at most one level can ever match, so scopeCombine is ignored.

Enrichment is additive and never revokes: a role already on subject.roles is deduplicated, never removed. If nothing matches, the original subject object is returned unchanged (no allocation).

Construction guards

Seven checks run in the constructor body, plus two more inside IamLRUCache's constructor when the caches are built. All are boot-time: a bad config is a failed start, not a surprise on the first request.

ConditionErrorMessage
policyCombine not one of the three valid valuesErrorunknown policyCombine ...
mode: 'production' with policyCombine: 'first-applicable'ErrorpolicyCombine 'first-applicable' requires mode 'development'; the production fast path cannot represent it correctly.
defaultEffect: 'allow' without allowFailOpen: trueErrordefaultEffect 'allow' is a fail-open footgun. Pass allowFailOpen: true to confirm intent.
maxPolicies non-finite or < 1RangeErrormaxPolicies must be a finite number >= 1
maxRoles non-finite or < 1RangeErrormaxRoles must be a finite number >= 1
adapterTimeoutMs non-finite or < 0RangeErroradapterTimeoutMs must be a finite number >= 0
maxConcurrentSubjectLoads non-finite, or neither 0 nor >= 1RangeErrormaxConcurrentSubjectLoads must be 0 (unbounded) or a finite number >= 1
cacheTTL non-finite or negativeRangeErrorttlMs must be a finite number >= 0 (from IamLRUCache)
maxCacheSize non-finite or < 1RangeErrormaxSize must be a finite number >= 1 (from IamLRUCache)

The engine's own messages are prefixed [@gentleduck/iam:engine]. The finiteness checks exist because NaN > x is always false: a NaN limit would silently disable the bound it was supposed to enforce rather than failing loudly. The policyCombine guard exists because both evaluators branch on 'and' and 'allow-overrides' and fall through to first-applicable — the most permissive of the three — for anything else, so a typo silently lost deny-overrides semantics.

AccessControl.IDecision

Development mode returns this object from authorize and check.

interface IDecision {
  readonly allowed: boolean
  readonly effect: 'allow' | 'deny'
  readonly rule?: AccessControl.IRule
  readonly policy?: string
  readonly reason: string
  readonly duration: number
  readonly timestamp: number
  readonly applicable?: boolean
  readonly failure?: 'input' | 'resolution' | 'evaluation'
}
FieldMeaning
allowedThe verdict. Always effect === 'allow'.
effect'allow' or 'deny'.
ruleThe AccessControl.IRule that decided, when one did. Absent on a default-effect verdict.
policyID of the deciding policy. '__rbac__' for the synthetic policy generated from roles.
reasonHuman-readable explanation, safe to log.
durationEvaluation time in milliseconds.
timestampDate.now() at decision time.
applicablefalse when a policy's targets did not match the request, so it contributed nothing to the cross-policy combine. Omitted otherwise.
failureSet only when the deny came from the engine failing rather than from a policy saying no: 'input' for a rejected request, 'resolution' for an unresolvable subject, 'evaluation' for a throw during evaluation. Absent on every ordinary decision. It is what lets a caller answer 403 for one and 503 for the other.

Three real shapes:

// Allowed by an RBAC-derived rule
{
  allowed: true,
  effect: 'allow',
  rule: { id: '__rbac__#3', effect: 'allow', priority: 10, /* ... */ },
  policy: '__rbac__',
  reason: 'Allowed by rule "__rbac__#3" (allow-overrides)',
  duration: 0.42,
  timestamp: 1708300000000,
}

// Nothing applicable, defaultEffect: 'deny'
{
  allowed: false,
  effect: 'deny',
  reason: 'No matching rules -> deny',
  duration: 0.31,
  timestamp: 1708300000000,
}

// Synthesized by the engine when subject resolution threw
{
  allowed: false,
  effect: 'deny',
  reason: 'Subject resolution error',
  duration: 0,
  timestamp: 1708300000000,
}

The synthesized shapes carry duration: 0 because no evaluation ran. The engine builds three of them: 'Evaluation error' from authorize's catch, 'Subject resolution error' from check's catch, and 'invalid subjectId' from check's argument guard.

Production mode returns a bare boolean from authorize and check, so there is nowhere to carry failure — use the onError hook. afterEvaluate and onDeny still fire in production, with a verdict-only decision synthesised for them; policy, rule and the interpreter's real reason are not in it, because the compiled table does not retain policy identity.

Module-level exports

import { IamEngine, iamEngine, iamFlushSharedCaches, IamLRUCache, iamBuildPermissionKey, iamParsePermissionKey, iamSplitPermissionKey } from '@gentleduck/iam'
import type { IamEngineTypes } from '@gentleduck/iam'
ExportWhat it is
IamEngineThe engine class.
iamEngine(config)Factory returning new IamEngine(config).
iamFlushSharedCaches()Clears the process-wide compiled-regex and dot-path caches. Not the per-engine ones.
IamLRUCache / iamLRUCacheThe TTL+LRU cache the engine uses internally, exported for adjacent application code.
iamBuildPermissionKey(action, resource, resourceId?, scope?)Builds the exact key format engine.permissions() returns: [@scope:]action:resource[:resourceId].
iamSplitPermissionKey(key)Tokenises a key into unescaped segments, honouring the \:, \\ and \@ escapes.
iamParsePermissionKey(key)The inverse of the builder: { scope, action, resource, resourceId }, or null for anything not in the builder's image.
IamEngineTypesNamespace holding IConfig, IHooks, IAdmin, IHealth, IMetricsEvent, IInvalidator, IInvalidateEvent, IMutationEvent, ISnapshot, IImportOptions, IImportResult.

Full example

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

const viewer = defineRole('viewer').grant('read', 'post').grant('read', 'comment').build()
const editor = defineRole('editor').inherits('viewer').grant('create', 'post').grant('update', 'post').build()

const adapter = new IamMemoryAdapter({
  roles: [viewer, editor],
  assignments: { 'user-1': ['editor'] },
})

const engine = new IamEngine({
  adapter,
  defaultEffect: 'deny',
  cacheTTL: 60,
  maxCacheSize: 5000,
  adapterTimeoutMs: 2_000,
  hooks: {
    onDeny: (req, decision) => console.warn('denied', req.subject.id, decision.reason),
    onPolicyError: (err, policyId) => console.error('broken policy', policyId, err),
  },
})

await engine.preload()

await engine.can('user-1', 'read', { type: 'post', attributes: {} }) // true - inherited from viewer
await engine.can('user-1', 'delete', { type: 'post', attributes: {} }) // false

await engine.admin.saveRole({
  id: 'admin',
  name: 'Admin',
  permissions: [{ action: 'delete', resource: 'post' }],
  inherits: ['editor'],
})
await engine.admin.assignRole('user-1', 'admin')

await engine.can('user-1', 'delete', { type: 'post', attributes: {} }) // true - caches were invalidated by the writes

Gotchas

  • The defaultEffect: 'allow' guard is not mode-gated. A development-mode engine refuses it too.
  • Naming TMode does not set mode. Pass mode explicitly.
  • cacheTTL is seconds, adapterTimeoutMs is milliseconds. Passing cacheTTL: 60000 gives you a 16-hour TTL.
  • iamFlushSharedCaches() is module-level, not an instance method. It wipes process-global regex and dot-path caches shared by every engine in the process; engine.cache.invalidate() does not touch them, and they are separate again from the per-engine regex/path caches each IamEngine owns.
  • Two engines never share evaluation caches. Each instance holds its own regex and dot-path Maps, so one tenant flooding an engine with hostile patterns cannot evict another tenant's entries.
  • The subject cache is the only sized cache. Raising maxCacheSize does nothing for policies or roles.

See also