Skip to main content

engine overview

The Engine class - central evaluator. Configuration, the IDecision object, and a tour of the API surface.

What the engine does

The IamEngine is the central evaluator. You create an engine with an adapter and optional configuration, then call its methods to check permissions. The engine loads roles and policies from the adapter, resolves the subject, runs the evaluation pipeline, and returns a decision.

Loading diagram...

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

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

const engine = new IamEngine({ adapter })

Reading order

PageCovers
methodscan, check, authorize, permissions, explain, preload, healthCheck, stats, dispose
hooksbeforeEvaluate, afterEvaluate, onDeny, onError, onPolicyError, onMetrics
cachingThe four LRU caches, invalidation, multi-instance via invalidator
admin APIengine.admin.* CRUD plus export() / import() snapshots
modesDevelopment vs production mode, performance trade-offs, production guards

IamEngineTypes.IConfig

The constructor accepts an IamEngineTypes.IConfig:

interface IConfig {
  adapter: IamAdapter.IAdapter             // Required. Storage backend.
  defaultEffect?: AccessControl.Effect  // Default 'deny'.
  cacheTTL?: number                     // Default 60 seconds.
  maxCacheSize?: number                 // Default 1000 subjects.
  mode?: AccessControl.Mode             // 'development' (default) | 'production'.
  hooks?: IamEngineTypes.IHooks            // Lifecycle hooks.
  adapterTimeoutMs?: number             // Per-adapter-call timeout (default 5000 ms).
  maxPolicies?: number                  // Hard cap on policies loaded (default 10000, fail-closed).
  maxRoles?: number                     // Hard cap on roles loaded (default 10000, fail-closed).
  allowFailOpen?: boolean               // Required to combine production + defaultEffect: 'allow'.
  invalidator?: IamEngineTypes.IInvalidator // Cross-instance cache invalidation broadcaster.
  policyCombine?: AccessControl.PolicyCombine // How multiple policies combine.
}
OptionTypeDefaultDescription
adapterIamAdapter.IAdapter--The storage backend. Required.
defaultEffect'allow' | 'deny''deny'Final effect when no rule matches
cacheTTLnumber60How long cached data lives, in seconds
maxCacheSizenumber1000Max subjects in the LRU cache
mode'development' | 'production''development'Evaluation mode - see modes
hooksIamEngineTypes.IHooks{}Lifecycle hooks - see hooks
adapterTimeoutMsnumber5000Aborts adapter reads via AbortController once the budget elapses. Set 0 to disable.
maxPoliciesnumber10000Hard cap on policies loaded from the adapter. Overruns throw at load time (fail-closed).
maxRolesnumber10000Hard cap on roles loaded from the adapter. Overruns throw at load time (fail-closed).
allowFailOpenbooleanfalseRequired when combining mode: 'production' with defaultEffect: 'allow'
invalidatorIamEngineTypes.IInvalidator--Cross-instance pub/sub broadcaster - see caching
policyCombine'and' | 'allow-overrides' | 'first-applicable''and'How decisions from multiple policies combine - see cross-policy

Production guards

The constructor refuses two combinations when mode: 'production' is set:

  • policyCombine: 'first-applicable' - throws. The fast path returns a plain boolean and cannot distinguish "rule fired" from "default applied", so first-applicable is dev-mode only.
  • defaultEffect: 'allow' without allowFailOpen: true - throws. Fail-open in a production authorization engine is a security footgun; opt in explicitly.

Both throw at construction time so misconfigurations never serve traffic.

Hooks reference

IamEngineTypes.IHooks accepts the lifecycle hooks plus two operational hooks:

HookWhen it firesDescription
beforeEvaluateBefore every evaluationMutate the request (enrich subject/resource attrs)
afterEvaluateAfter every evaluation (dev mode)Audit logging, metrics on the resulting decision
onDenyOnly when the decision is denySecurity alerts, denial logging
onErrorAny uncaught error during evaluationError tracking; engine then fails closed
onMetricsAfter each evaluation in either modeReceives timing + cache-hit telemetry for dashboards
onPolicyErrorWhen a single policy throws during evaluationPer-policy error capture without aborting evaluation

See hooks for full signatures and examples.


Full configuration example

const engine = new IamEngine({
  adapter,
  defaultEffect: 'deny',
  cacheTTL: 120,
  maxCacheSize: 5000,
  mode: 'production',
  hooks: {
    beforeEvaluate: (request) => ({
      ...request,
      environment: { ...request.environment, timestamp: Date.now() },
    }),
    afterEvaluate: (request, decision) => {
      console.log(`[access] ${request.subject.id} ${request.action} ${request.resource.type} -> ${decision.effect}`)
    },
    onDeny: (request, decision) => {
      auditLog.write({
        event: 'access_denied',
        subject: request.subject.id,
        action: request.action,
        resource: request.resource.type,
        reason: decision.reason,
      })
    },
    onError: (error, request) => {
      errorTracker.capture(error, { subject: request.subject.id })
    },
  },
})

The IDecision object

Every authorization check in development mode returns an AccessControl.IDecision:

interface IDecision {
  allowed: boolean                 // The final yes/no answer
  effect: AccessControl.Effect     // 'allow' | 'deny'
  rule?: AccessControl.IRule       // Which rule decided
  policy?: string                  // Which policy ID
  reason: string                   // Human-readable explanation
  duration: number                 // Evaluation time in milliseconds
  timestamp: number                // When the decision was made (Date.now())
}

Examples

Allowed by a matching rule:

{
  allowed: true,
  effect: 'allow',
  rule: { id: 'rbac-editor-create-post', /* ... */ },
  policy: '__rbac__',
  reason: 'Allowed by rule "rbac-editor-create-post" (allow-overrides)',
  duration: 0.42,
  timestamp: 1708300000000,
}

Denied - no rules matched:

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

Denied by an explicit deny rule:

{
  allowed: false,
  effect: 'deny',
  rule: { id: 'block-external-ips', /* ... */ },
  policy: 'ip-restriction-policy',
  reason: 'Denied by rule "block-external-ips"',
  duration: 0.55,
  timestamp: 1708300000000,
}

In production mode, engine.authorize() returns a plain boolean - no IDecision allocation. See modes.


Low-level helpers

Most applications only need the IamEngine, but two adjacent exports are worth knowing about:

import { IamLRUCache, iamBuildPermissionKey } from '@gentleduck/iam'
  • iamBuildPermissionKey(action, resource, resourceId?, scope?) matches the exact key format used by engine.permissions() and all client libraries
  • IamLRUCache is the small TTL cache implementation the engine uses internally - import only if you want the same eviction semantics in neighboring application code

Putting it together

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

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,
  hooks: {
    afterEvaluate: (req, d) => console.log(`${req.subject.id} ${req.action}:${req.resource.type} = ${d.effect}`),
  },
})

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

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

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