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.
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' })
IamEngine, iamEngine, iamFlushSharedCaches, the builders, and every type
come from @gentleduck/iam. Adapters do not - import IamMemoryAdapter from
@gentleduck/iam/adapters/memory, the Prisma adapter from
@gentleduck/iam/adapters/prisma, and so on. See installation.Where a request goes
One engine.can() call goes through these stages.
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
| Page | Covers |
|---|---|
| Engine methods | authorize, can, check, getEffectiveRoles, permissions, explain, preload, healthCheck, setInvalidator, withTransaction, dispose, stats |
| Caching and invalidation | The five LRU caches, every key, single-flight, every invalidation trigger and its ordering |
| Hooks | The seven hooks, their arguments, firing order, and error semantics |
| Development vs production mode | What each mode returns, which hooks fire, which options are refused |
| Compiled table | Production's data structure: role bitmasks, the 32-role cap, residual policies |
| Admin API | engine.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
| Option | Type | Default | Meaning |
|---|---|---|---|
adapter | IamAdapter.IAdapter | required | Storage backend for policies, roles, assignments and attributes |
defaultEffect | 'allow' | 'deny' | 'deny' | The vote cast when a source is applicable but no rule fired |
cacheTTL | number | 60 | Cache entry lifetime in seconds; multiplied by 1000 internally |
maxCacheSize | number | 1000 | Maximum entries in the subject cache only |
mode | 'development' | 'production' | 'production' | Return shape, and whether the interpreter runs alongside the compiled table |
hooks | IamEngineTypes.IHooks | {} | Lifecycle hooks |
policyCombine | 'and' | 'allow-overrides' | 'first-applicable' | 'and' | How verdicts from separate policies merge |
maxPolicies | number | 10000 | Hard ceiling on policies read from the adapter |
maxRoles | number | 10000 | Hard ceiling on roles read from the adapter |
allowFailOpen | boolean | false | Required acknowledgement for defaultEffect: 'allow' |
adapterTimeoutMs | number | 5000 | Per-adapter-call timeout; 0 disables |
maxConcurrentSubjectLoads | number | 512 | Cap on concurrent distinct-subject loads; 0 restores unbounded |
invalidator | IamEngineTypes.IInvalidator | none | Cross-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:
cacheTTLis in seconds;adapterTimeoutMsis in milliseconds.maxCacheSizesizes the subject cache. The other four caches are hardcoded to a single entry, because each holds one whole collection.cacheTTL: 0does 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-readslistRolesandlistPoliciesand re-runscompileTable. Correct, and very slow. Tests only.maxConcurrentSubjectLoadsbounds 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-closedfalseplusonError, not as a throw at the call site.
Scope options
scopeMode and scopeCombine govern enrichSubjectWithScopedRoles, which merges subject.scopedRoles into subject.roles before evaluation.
scopeMode | Match 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.
defaultEffect: 'allow' throws unless allowFailOpen: true is also passed - in
development mode too, not only production. Even with the opt-in, the
constructor emits a console.warn naming the engine as fail-open, so an
operator grepping startup logs always finds it.| Condition | Error | Message |
|---|---|---|
policyCombine not one of the three valid values | Error | unknown policyCombine ... |
mode: 'production' with policyCombine: 'first-applicable' | Error | policyCombine 'first-applicable' requires mode 'development'; the production fast path cannot represent it correctly. |
defaultEffect: 'allow' without allowFailOpen: true | Error | defaultEffect 'allow' is a fail-open footgun. Pass allowFailOpen: true to confirm intent. |
maxPolicies non-finite or < 1 | RangeError | maxPolicies must be a finite number >= 1 |
maxRoles non-finite or < 1 | RangeError | maxRoles must be a finite number >= 1 |
adapterTimeoutMs non-finite or < 0 | RangeError | adapterTimeoutMs must be a finite number >= 0 |
maxConcurrentSubjectLoads non-finite, or neither 0 nor >= 1 | RangeError | maxConcurrentSubjectLoads must be 0 (unbounded) or a finite number >= 1 |
cacheTTL non-finite or negative | RangeError | ttlMs must be a finite number >= 0 (from IamLRUCache) |
maxCacheSize non-finite or < 1 | RangeError | maxSize 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'
}
| Field | Meaning |
|---|---|
allowed | The verdict. Always effect === 'allow'. |
effect | 'allow' or 'deny'. |
rule | The AccessControl.IRule that decided, when one did. Absent on a default-effect verdict. |
policy | ID of the deciding policy. '__rbac__' for the synthetic policy generated from roles. |
reason | Human-readable explanation, safe to log. |
duration | Evaluation time in milliseconds. |
timestamp | Date.now() at decision time. |
applicable | false when a policy's targets did not match the request, so it contributed nothing to the cross-policy combine. Omitted otherwise. |
failure | Set 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'
| Export | What it is |
|---|---|
IamEngine | The 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 / iamLRUCache | The 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. |
IamEngineTypes | Namespace 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
TModedoes not setmode. Passmodeexplicitly. cacheTTLis seconds,adapterTimeoutMsis milliseconds. PassingcacheTTL: 60000gives 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 eachIamEngineowns.- 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
maxCacheSizedoes nothing for policies or roles.