chapter 4: the engine in depth
Every engine method, hook, cache, batch permission, explain/debug, and Admin API. Build a complete authorization backend.
Goal
The engine is more than engine.can(). This chapter covers every engine method, hooks for
enrichment and logging, caching, batch permission checks, the explain API, and the Admin API.
Engine Methods Overview
| Method | Returns (dev) | Returns (prod) | Description |
|---|---|---|---|
engine.can(subjectId, action, resource, env?, scope?) | boolean | boolean | Simple yes/no permission check |
engine.check(subjectId, action, resource, env?, scope?) | Decision | boolean | Full decision in dev, fast boolean in prod |
engine.authorize(request) | Decision | Decision | Low-level: takes a full AccessRequest |
engine.permissions(subjectId, checks, env?) | PermissionMap | Record<string, boolean> | Batch check multiple permissions |
engine.explain(subjectId, action, resource, env?, scope?) | ExplainResult | n/a (dev only) | Debug trace showing every evaluation step |
engine.can() and engine.check()
// Boolean check -- most common
const allowed = await engine.can('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// true
// Full decision
const decision = await engine.check('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// { allowed: true, effect: 'allow', reason: '...', duration: 0.5, timestamp: 1708300000000 }// Boolean check -- most common
const allowed = await engine.can('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// true
// Full decision
const decision = await engine.check('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// { allowed: true, effect: 'allow', reason: '...', duration: 0.5, timestamp: 1708300000000 }Both accept optional environment and scope parameters:
await engine.can('bob', 'update',
{ type: 'post', id: 'post-1', attributes: { ownerId: 'bob' } },
{ ip: '192.168.1.1', timestamp: Date.now() }, // environment
'acme', // scope
)await engine.can('bob', 'update',
{ type: 'post', id: 'post-1', attributes: { ownerId: 'bob' } },
{ ip: '192.168.1.1', timestamp: Date.now() }, // environment
'acme', // scope
)engine.authorize()
The low-level method takes a full AccessRequest object:
import type { AccessRequest } from '@gentleduck/iam'
const request: AccessRequest = {
subject: {
id: 'bob',
roles: ['editor', 'viewer'],
attributes: { department: 'engineering' },
},
action: 'update',
resource: {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
},
environment: { ip: '192.168.1.1' },
scope: 'acme',
}
const decision = await engine.authorize(request)import type { AccessRequest } from '@gentleduck/iam'
const request: AccessRequest = {
subject: {
id: 'bob',
roles: ['editor', 'viewer'],
attributes: { department: 'engineering' },
},
action: 'update',
resource: {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
},
environment: { ip: '192.168.1.1' },
scope: 'acme',
}
const decision = await engine.authorize(request)When you call can() or check(), the engine internally resolves the subject
from the adapter (loading roles, scoped roles, and attributes), then calls authorize().
Use authorize() directly when you already have a fully resolved subject and want to skip
the adapter lookup.
Dev vs Prod Mode
The engine supports two modes controlling what check() and permissions() return.
Development mode (default) returns rich Decision objects and enables explain().
Production mode strips that overhead and returns booleans.
import { Engine } from '@gentleduck/iam'
// Development mode (default) -- rich Decision objects, explain() available
const devEngine = new Engine({ adapter })
// Production mode -- fast booleans, no explain()
const prodEngine = new Engine({ adapter, mode: 'production' })import { Engine } from '@gentleduck/iam'
// Development mode (default) -- rich Decision objects, explain() available
const devEngine = new Engine({ adapter })
// Production mode -- fast booleans, no explain()
const prodEngine = new Engine({ adapter, mode: 'production' })What Changes Between Modes
| Method | Development (default) | Production |
|---|---|---|
can() | boolean | boolean |
check() | Decision (with reason, timing, effect) | boolean |
permissions() | PermissionMap (values are Decision) | Record<string, boolean> |
explain() | ExplainResult | not available |
can() always returns a plain boolean regardless of mode.
check() is mode-dependent. Its return type is ModeResult<TMode>: in development
mode that resolves to Decision, in production mode it resolves to boolean. Switch to
production mode for zero-overhead boolean checks without changing call sites.
explain() is only available in development mode. It evaluates all rules without
short-circuiting and returns a full debug trace.
Development Mode
const engine = new Engine({ adapter })
// check() returns a full Decision
const decision = await engine.check('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// { allowed: true, effect: 'allow', reason: '...', duration: 0.5, timestamp: 1708300000000 }
// permissions() returns a PermissionMap with Decision values
const perms = await engine.permissions('bob', [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
])
// { 'create:post': { allowed: true, effect: 'allow', ... }, 'delete:post:post-1': { allowed: false, ... } }
// explain() is available
const trace = await engine.explain('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
console.log(trace.summary)const engine = new Engine({ adapter })
// check() returns a full Decision
const decision = await engine.check('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// { allowed: true, effect: 'allow', reason: '...', duration: 0.5, timestamp: 1708300000000 }
// permissions() returns a PermissionMap with Decision values
const perms = await engine.permissions('bob', [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
])
// { 'create:post': { allowed: true, effect: 'allow', ... }, 'delete:post:post-1': { allowed: false, ... } }
// explain() is available
const trace = await engine.explain('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
console.log(trace.summary)Production Mode
const engine = new Engine({ adapter, mode: 'production' })
// can() still returns boolean (same as dev)
const allowed = await engine.can('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// true
// check() now returns boolean instead of Decision
const result = await engine.check('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// true (not a Decision object)
// permissions() returns Record<string, boolean>
const perms = await engine.permissions('bob', [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
])
// { 'create:post': true, 'delete:post:post-1': false }
// explain() is NOT available in production modeconst engine = new Engine({ adapter, mode: 'production' })
// can() still returns boolean (same as dev)
const allowed = await engine.can('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// true
// check() now returns boolean instead of Decision
const result = await engine.check('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
// true (not a Decision object)
// permissions() returns Record<string, boolean>
const perms = await engine.permissions('bob', [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
])
// { 'create:post': true, 'delete:post:post-1': false }
// explain() is NOT available in production modeProduction mode is ~2x faster because it skips: performance.now() timing, Date.now() timestamps, Decision object allocation, reason string generation, and afterEvaluate/onDeny/onError hooks. It also uses rule indexing (Map lookup by action:resource) instead of iterating all rules.
Choosing a Mode
Use development mode when you need to:
- Debug why a permission was denied (inspect
Decision.reason) - Trace evaluation across policies and rules (
explain()) - Measure evaluation performance (
Decision.duration) - Write tests that assert on decision details
Use production mode when you need to:
- Minimize overhead in deployed services
- Return simple booleans to API consumers
- Avoid exposing internal policy details
You can run both modes side by side. For example, a production engine for your hot path and a development engine for an admin debug endpoint.
Hooks
Hooks run at key points in the evaluation lifecycle.
Hook Type Signatures
interface EngineHooks {
// Runs before evaluation. Can modify the request (enrich with DB data).
beforeEvaluate?(
request: AccessRequest,
): AccessRequest | Promise<AccessRequest>
// Runs after evaluation, regardless of outcome.
afterEvaluate?(
request: AccessRequest,
decision: Decision,
): void | Promise<void>
// Runs only when the decision is deny.
onDeny?(
request: AccessRequest,
decision: Decision,
): void | Promise<void>
// Runs if any error occurs during evaluation.
onError?(
error: Error,
request: AccessRequest,
): void | Promise<void>
}interface EngineHooks {
// Runs before evaluation. Can modify the request (enrich with DB data).
beforeEvaluate?(
request: AccessRequest,
): AccessRequest | Promise<AccessRequest>
// Runs after evaluation, regardless of outcome.
afterEvaluate?(
request: AccessRequest,
decision: Decision,
): void | Promise<void>
// Runs only when the decision is deny.
onDeny?(
request: AccessRequest,
decision: Decision,
): void | Promise<void>
// Runs if any error occurs during evaluation.
onError?(
error: Error,
request: AccessRequest,
): void | Promise<void>
}beforeEvaluate: enrich the request
Fetch resource data from a database before evaluation runs:
export const engine = new Engine({
adapter,
hooks: {
beforeEvaluate: async (request) => {
if (request.resource.type !== 'post') return request
// Fetch the post to get its ownerId
const post = await db.posts.findUnique({
where: { id: request.resource.id },
})
return {
...request,
resource: {
...request.resource,
attributes: {
...request.resource.attributes,
ownerId: post?.authorId,
},
},
}
},
},
})export const engine = new Engine({
adapter,
hooks: {
beforeEvaluate: async (request) => {
if (request.resource.type !== 'post') return request
// Fetch the post to get its ownerId
const post = await db.posts.findUnique({
where: { id: request.resource.id },
})
return {
...request,
resource: {
...request.resource,
attributes: {
...request.resource.attributes,
ownerId: post?.authorId,
},
},
}
},
},
})Callers don't need to pass ownerId; the hook fetches it automatically. The hook
receives the full AccessRequest and returns a (potentially modified) request. You can
modify any part: subject attributes, resource attributes, environment, etc.
If beforeEvaluate throws, evaluation is skipped and the result is deny (fail closed).
The onError hook is called.
afterEvaluate: audit logging
hooks: {
afterEvaluate: async (request, decision) => {
console.log(
`[audit] ${request.subject.id} ${decision.effect} ${request.action}` +
` on ${request.resource.type}:${request.resource.id ?? 'any'}`
)
},
}hooks: {
afterEvaluate: async (request, decision) => {
console.log(
`[audit] ${request.subject.id} ${decision.effect} ${request.action}` +
` on ${request.resource.type}:${request.resource.id ?? 'any'}`
)
},
}afterEvaluate runs regardless of outcome (allow or deny). Use it for audit trails,
metrics, and analytics.
onDeny: alert on denied access
hooks: {
onDeny: async (request, decision) => {
metrics.increment('access.denied', {
action: request.action,
resource: request.resource.type,
subject: request.subject.id,
reason: decision.reason,
})
},
}hooks: {
onDeny: async (request, decision) => {
metrics.increment('access.denied', {
action: request.action,
resource: request.resource.type,
subject: request.subject.id,
reason: decision.reason,
})
},
}onDeny runs only when the decision is deny, after afterEvaluate.
onError: handle evaluation failures
hooks: {
onError: async (error, request) => {
logger.error('Authorization error', {
error: error.message,
subjectId: request.subject.id,
action: request.action,
resource: request.resource.type,
})
},
}hooks: {
onError: async (error, request) => {
logger.error('Authorization error', {
error: error.message,
subjectId: request.subject.id,
action: request.action,
resource: request.resource.type,
})
},
}If any error occurs during evaluation (including in hooks), the engine catches it,
calls onError, and returns deny. Errors never result in accidental allows.
The deny decision includes the error message:
{ allowed: false, reason: 'Evaluation error: ...' }
Hooks in Batch Permissions
engine.permissions() triggers hooks for each check in the batch. Each permission
check goes through beforeEvaluate, evaluation, afterEvaluate, and onDeny (if denied).
Hooks in Explain
engine.explain() only triggers beforeEvaluate, not afterEvaluate, onDeny, or
onError. The hook may modify the request, which affects the evaluation trace. If subject
resolution, beforeEvaluate, or policy loading throws, the explain() call rejects.
Caching
The engine maintains four LRU caches to avoid hitting the adapter on every check:
How Caching Works
- Policy cache (1 entry): stores the result of
adapter.listPolicies(). All policies are loaded once and cached together. - Role cache (1 entry): stores the result of
adapter.listRoles(). All roles are loaded once and cached together. - RBAC policy cache (1 entry): stores the
__rbac__policy generated from roles. Regenerated only when the role cache is invalidated. - Subject cache (up to
maxCacheSizeentries): stores per-user data: resolved roles, scoped roles, and attributes. Uses LRU eviction when full.
All caches use TTL. Entries expire after cacheTTL seconds and are re-fetched from the
adapter on the next access.
Configuration
const engine = new Engine({
adapter,
cacheTTL: 60, // seconds (default: 60, set to 0 to disable)
maxCacheSize: 1000, // max cached subjects (default: 1000)
})const engine = new Engine({
adapter,
cacheTTL: 60, // seconds (default: 60, set to 0 to disable)
maxCacheSize: 1000, // max cached subjects (default: 1000)
})cacheTTL: 0disables caching entirely (useful for tests)- The subject cache uses LRU eviction: least recently used entries are dropped first
- Policy and role caches are single-entry, storing all data in one cache slot
Manual Invalidation
engine.invalidate() // clear ALL caches
engine.invalidateSubject('user-1') // clear one user's cached data
engine.invalidatePolicies() // clear policy cache only
engine.invalidateRoles() // clear role + RBAC + ALL subject cachesengine.invalidate() // clear ALL caches
engine.invalidateSubject('user-1') // clear one user's cached data
engine.invalidatePolicies() // clear policy cache only
engine.invalidateRoles() // clear role + RBAC + ALL subject cachesinvalidateRoles() also clears the subject cache because subjects cache their resolved
roles. If role definitions change, all cached subject data becomes stale.
Batch Permissions
For UIs checking 10-20 permissions at once, use engine.permissions():
const checks = [
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post', resourceId: 'post-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
{ action: 'manage', resource: 'dashboard' },
{ action: 'manage', resource: 'user', scope: 'acme' },
]
const perms = await engine.permissions('bob', checks)
// {
// 'create:post': true,
// 'update:post:post-1': true,
// 'delete:post:post-1': false,
// 'manage:dashboard': false,
// 'acme:manage:user': true,
// }const checks = [
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post', resourceId: 'post-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
{ action: 'manage', resource: 'dashboard' },
{ action: 'manage', resource: 'user', scope: 'acme' },
]
const perms = await engine.permissions('bob', checks)
// {
// 'create:post': true,
// 'update:post:post-1': true,
// 'delete:post:post-1': false,
// 'manage:dashboard': false,
// 'acme:manage:user': true,
// }PermissionCheck Type
interface PermissionCheck {
action: string // the action to check
resource: string // the resource type
resourceId?: string // optional: specific resource instance
scope?: string // optional: scope for this check
}interface PermissionCheck {
action: string // the action to check
resource: string // the resource type
resourceId?: string // optional: specific resource instance
scope?: string // optional: scope for this check
}PermissionMap Key Format
| Format | Example | When |
|---|---|---|
action:resource | 'create:post' | No resourceId, no scope |
action:resource:resourceId | 'update:post:post-1' | With resourceId |
scope:action:resource | 'acme:manage:user' | With scope |
scope:action:resource:resourceId | 'acme:update:post:post-1' | Both scope and resourceId |
Performance
permissions() is faster than calling can() in a loop: subject data is loaded once,
policies are loaded once, and the adapter is queried once rather than N times.
Each check still goes through the full evaluation pipeline including hooks. If any check
throws, it defaults to false and onError is called.
Environment in Batch
const perms = await engine.permissions('bob', checks, {
ip: '192.168.1.1',
timestamp: Date.now(),
})const perms = await engine.permissions('bob', checks, {
ip: '192.168.1.1',
timestamp: Date.now(),
})Explain and Debug
When a permission check returns an unexpected result, use engine.explain():
const result = await engine.explain('bob', 'update', {
type: 'post',
id: 'post-2',
attributes: { ownerId: 'alice' },
})
console.log(result.summary)const result = await engine.explain('bob', 'update', {
type: 'post',
id: 'post-2',
attributes: { ownerId: 'alice' },
})
console.log(result.summary)Output:
DENIED: "bob" -> update on post
Roles: [editor, viewer]
__rbac__ [allow-overrides]: Allowed by rule "rbac.editor.update.post.0" (1/6 rules matched)
owner-restrictions [deny-overrides]: Denied by rule "deny-non-owner-update" (1/1 rules matched)
Result: Denied by rule "deny-non-owner-update"DENIED: "bob" -> update on post
Roles: [editor, viewer]
__rbac__ [allow-overrides]: Allowed by rule "rbac.editor.update.post.0" (1/6 rules matched)
owner-restrictions [deny-overrides]: Denied by rule "deny-non-owner-update" (1/1 rules matched)
Result: Denied by rule "deny-non-owner-update"ExplainResult Structure
interface ExplainResult {
decision: Decision // the final decision
request: {
action: string
resourceType: string
resourceId?: string
scope?: string
}
subject: {
id: string
roles: string[] // base roles
scopedRolesApplied: string[] // additional scoped roles added
attributes: Record<string, any>
}
policies: PolicyTrace[] // trace for each policy
summary: string // human-readable summary
}interface ExplainResult {
decision: Decision // the final decision
request: {
action: string
resourceType: string
resourceId?: string
scope?: string
}
subject: {
id: string
roles: string[] // base roles
scopedRolesApplied: string[] // additional scoped roles added
attributes: Record<string, any>
}
policies: PolicyTrace[] // trace for each policy
summary: string // human-readable summary
}PolicyTrace and RuleTrace
interface PolicyTrace {
policyId: string
policyName: string
algorithm: CombiningAlgorithm
targetMatch: boolean // did the policy targets match?
rules: RuleTrace[] // trace for each rule
result: Effect // this policy's result
reason: string // why this policy decided this way
decidingRuleId?: string // which rule decided (if any)
}
interface RuleTrace {
ruleId: string
description?: string
effect: Effect
priority: number
actionMatch: boolean // did the action match?
resourceMatch: boolean // did the resource match?
conditionsMet: boolean // did all conditions pass?
conditions: ConditionGroupTrace // detailed condition trace
matched: boolean // actionMatch AND resourceMatch AND conditionsMet
}interface PolicyTrace {
policyId: string
policyName: string
algorithm: CombiningAlgorithm
targetMatch: boolean // did the policy targets match?
rules: RuleTrace[] // trace for each rule
result: Effect // this policy's result
reason: string // why this policy decided this way
decidingRuleId?: string // which rule decided (if any)
}
interface RuleTrace {
ruleId: string
description?: string
effect: Effect
priority: number
actionMatch: boolean // did the action match?
resourceMatch: boolean // did the resource match?
conditionsMet: boolean // did all conditions pass?
conditions: ConditionGroupTrace // detailed condition trace
matched: boolean // actionMatch AND resourceMatch AND conditionsMet
}ConditionTrace
// Leaf condition trace
interface ConditionLeafTrace {
type: 'condition'
field: string // e.g., 'resource.attributes.ownerId'
operator: Operator // e.g., 'neq'
expected: any // e.g., 'bob' (resolved from $subject.id)
actual: any // e.g., 'alice' (resolved from the resource)
result: boolean // true (alice neq bob = true)
}
// Group condition trace
interface ConditionGroupTrace {
type: 'group'
logic: 'all' | 'any' | 'none'
result: boolean
children: (ConditionLeafTrace | ConditionGroupTrace)[]
}// Leaf condition trace
interface ConditionLeafTrace {
type: 'condition'
field: string // e.g., 'resource.attributes.ownerId'
operator: Operator // e.g., 'neq'
expected: any // e.g., 'bob' (resolved from $subject.id)
actual: any // e.g., 'alice' (resolved from the resource)
result: boolean // true (alice neq bob = true)
}
// Group condition trace
interface ConditionGroupTrace {
type: 'group'
logic: 'all' | 'any' | 'none'
result: boolean
children: (ConditionLeafTrace | ConditionGroupTrace)[]
}Explain Does Not Short-Circuit
Unlike normal evaluation, explain() evaluates all rules in all policies, even after a
deny is found. Normal evaluate() stops at the first denying policy for performance.
Admin API
Manage authorization data at runtime through engine.admin:
// Policies
await engine.admin.listPolicies()
await engine.admin.getPolicy('owner-restrictions')
await engine.admin.savePolicy(newPolicy)
await engine.admin.deletePolicy('old-policy-id')
// Roles
await engine.admin.listRoles()
await engine.admin.getRole('editor')
await engine.admin.saveRole(newRole)
await engine.admin.deleteRole('old-role-id')
// Subject management
await engine.admin.assignRole('user-1', 'editor')
await engine.admin.assignRole('user-1', 'admin', 'acme') // scoped
await engine.admin.revokeRole('user-1', 'editor')
await engine.admin.revokeRole('user-1', 'admin', 'acme') // scoped
// Subject attributes
await engine.admin.setAttributes('user-1', { department: 'engineering' })
const attrs = await engine.admin.getAttributes('user-1')// Policies
await engine.admin.listPolicies()
await engine.admin.getPolicy('owner-restrictions')
await engine.admin.savePolicy(newPolicy)
await engine.admin.deletePolicy('old-policy-id')
// Roles
await engine.admin.listRoles()
await engine.admin.getRole('editor')
await engine.admin.saveRole(newRole)
await engine.admin.deleteRole('old-role-id')
// Subject management
await engine.admin.assignRole('user-1', 'editor')
await engine.admin.assignRole('user-1', 'admin', 'acme') // scoped
await engine.admin.revokeRole('user-1', 'editor')
await engine.admin.revokeRole('user-1', 'admin', 'acme') // scoped
// Subject attributes
await engine.admin.setAttributes('user-1', { department: 'engineering' })
const attrs = await engine.admin.getAttributes('user-1')Complete Admin API
| Method | Description | Cache Invalidation |
|---|---|---|
listPolicies() | List all policies | none |
getPolicy(id) | Get a single policy | none |
savePolicy(policy) | Create or update a policy | clears policy cache |
deletePolicy(id) | Delete a policy | clears policy cache |
listRoles() | List all roles | none |
getRole(id) | Get a single role | none |
saveRole(role) | Create or update a role | clears role + RBAC + subject caches |
deleteRole(id) | Delete a role | clears role + RBAC + subject caches |
assignRole(subjectId, roleId, scope?) | Assign a role to a subject | clears that subject's cache |
revokeRole(subjectId, roleId, scope?) | Revoke a role from a subject | clears that subject's cache |
setAttributes(subjectId, attrs) | Set subject attributes | clears that subject's cache |
getAttributes(subjectId) | Get subject attributes | none |
Every mutation automatically invalidates the relevant cache. When you call
admin.assignRole('user-1', 'editor'), the user-1 subject cache is cleared so the next
check picks up the new role. No manual invalidation needed after admin operations.