engine api
Complete reference for the duck-iam Engine class -- core methods, configuration options, hooks, caching, admin operations, and the Decision object.
Overview
The Engine is the central evaluator in duck-iam. 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.
import { Engine } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: { 'user-1': ['editor'] },
})
const engine = new Engine({ adapter })import { Engine } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: { 'user-1': ['editor'] },
})
const engine = new Engine({ adapter })EngineConfig
The Engine constructor accepts an EngineConfig object:
interface EngineConfig {
adapter: Adapter // Required. Storage backend for policies, roles, and subjects.
defaultEffect?: Effect // What to return when no rules match. Default: 'deny'.
cacheTTL?: number // Cache time-to-live in seconds. Default: 60.
maxCacheSize?: number // Maximum entries in the subject cache. Default: 1000.
hooks?: EngineHooks // Lifecycle hooks for observing and modifying evaluations.
}interface EngineConfig {
adapter: Adapter // Required. Storage backend for policies, roles, and subjects.
defaultEffect?: Effect // What to return when no rules match. Default: 'deny'.
cacheTTL?: number // Cache time-to-live in seconds. Default: 60.
maxCacheSize?: number // Maximum entries in the subject cache. Default: 1000.
hooks?: EngineHooks // Lifecycle hooks for observing and modifying evaluations.
}| Option | Type | Default | Description |
|---|---|---|---|
adapter | Adapter | -- | The storage backend. Required. |
defaultEffect | 'allow' | 'deny' | 'deny' | Decision when no rules match. Use 'deny' for defense-in-depth. |
cacheTTL | number | 60 | How long cached data lives, in seconds. |
maxCacheSize | number | 1000 | Maximum number of resolved subjects in the LRU cache. |
hooks | EngineHooks | {} | Lifecycle hooks. See the Hooks section. |
Full configuration example
const engine = new Engine({
adapter,
defaultEffect: 'deny',
cacheTTL: 120,
maxCacheSize: 5000,
hooks: {
beforeEvaluate: (request) => {
// Enrich with server timestamp
return {
...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 })
},
},
})const engine = new Engine({
adapter,
defaultEffect: 'deny',
cacheTTL: 120,
maxCacheSize: 5000,
hooks: {
beforeEvaluate: (request) => {
// Enrich with server timestamp
return {
...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 })
},
},
})Core Methods
engine.can()
The simplest check. Returns true or false.
const allowed = await engine.can(
'user-1', // subject ID
'update', // action
{ type: 'post', attributes: {} }, // resource
)
if (!allowed) {
throw new Error('Forbidden')
}const allowed = await engine.can(
'user-1', // subject ID
'update', // action
{ type: 'post', attributes: {} }, // resource
)
if (!allowed) {
throw new Error('Forbidden')
}Signature:
engine.can(
subjectId: string,
action: string,
resource: Resource,
environment?: Environment,
scope?: string,
) -> Promise<boolean>engine.can(
subjectId: string,
action: string,
resource: Resource,
environment?: Environment,
scope?: string,
) -> Promise<boolean>engine.check()
Same as can() but returns the full Decision object instead of a boolean.
const decision = await engine.check('user-1', 'delete', {
type: 'post',
id: 'post-123',
attributes: { ownerId: 'user-2' },
})
if (!decision.allowed) {
console.log('Denied:', decision.reason)
// "Denied: No matching rules -> deny"
}const decision = await engine.check('user-1', 'delete', {
type: 'post',
id: 'post-123',
attributes: { ownerId: 'user-2' },
})
if (!decision.allowed) {
console.log('Denied:', decision.reason)
// "Denied: No matching rules -> deny"
}engine.authorize()
Takes a complete AccessRequest object. This is the low-level method that can() and
check() call internally. Use it when you already have a resolved subject.
const subject = await engine.resolveSubject('user-1')
const decision = await engine.authorize({
subject,
action: 'update',
resource: { type: 'post', id: 'post-123', attributes: { ownerId: 'user-1' } },
environment: { ip: '192.168.1.1' },
scope: 'org-1',
})const subject = await engine.resolveSubject('user-1')
const decision = await engine.authorize({
subject,
action: 'update',
resource: { type: 'post', id: 'post-123', attributes: { ownerId: 'user-1' } },
environment: { ip: '192.168.1.1' },
scope: 'org-1',
})If the request includes a scope, the engine enriches the subject with matching scoped
roles before evaluation. For example, if the subject has scopedRoles: [{ role: 'admin', scope: 'org-1' }] and the request scope is 'org-1', 'admin' is added to the effective
roles for this check.
If any error occurs during evaluation (adapter failure, hook exception, etc.), the engine
catches it, calls the onError hook if defined, and returns a deny decision with the
error message as the reason. The engine never throws from authorize().
engine.permissions()
Batch check multiple permissions at once. Loads data once, evaluates many. Returns a
PermissionMap keyed by "action:resource" or "scope:action:resource".
const perms = await engine.permissions('user-1', [
{ action: 'read', resource: 'post' },
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post' },
{ action: 'manage', resource: 'user' },
])
// perms = {
// 'read:post': true,
// 'create:post': true,
// 'delete:post': false,
// 'manage:user': false,
// }const perms = await engine.permissions('user-1', [
{ action: 'read', resource: 'post' },
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post' },
{ action: 'manage', resource: 'user' },
])
// perms = {
// 'read:post': true,
// 'create:post': true,
// 'delete:post': false,
// 'manage:user': false,
// }This is the method you should use when rendering a UI that needs to know many permissions at once (e.g., which buttons to show/hide).
With scopes and resource IDs:
const perms = await engine.permissions('user-1', [
{ action: 'update', resource: 'post', resourceId: 'post-123', scope: 'org-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-123', scope: 'org-1' },
])
// perms = {
// 'org-1:update:post:post-123': true,
// 'org-1:delete:post:post-123': false,
// }const perms = await engine.permissions('user-1', [
{ action: 'update', resource: 'post', resourceId: 'post-123', scope: 'org-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-123', scope: 'org-1' },
])
// perms = {
// 'org-1:update:post:post-123': true,
// 'org-1:delete:post:post-123': false,
// }engine.explain()
Returns a full evaluation trace. See the Explain and Debug page for complete documentation.
const result = await engine.explain('user-1', 'delete', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'user-2' },
})
console.log(result.summary)const result = await engine.explain('user-1', 'delete', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'user-2' },
})
console.log(result.summary)engine.resolveSubject()
Loads a subject's roles, scoped roles, and attributes from the adapter. The result is
cached using the LRU cache. Role inheritance is resolved here -- the returned roles
array includes all inherited roles, not just directly assigned ones.
const subject = await engine.resolveSubject('user-1')
// {
// id: 'user-1',
// roles: ['editor', 'viewer'], // includes inherited roles
// scopedRoles: [{ role: 'admin', scope: 'org-1' }],
// attributes: { department: 'engineering' }
// }const subject = await engine.resolveSubject('user-1')
// {
// id: 'user-1',
// roles: ['editor', 'viewer'], // includes inherited roles
// scopedRoles: [{ role: 'admin', scope: 'org-1' }],
// attributes: { department: 'engineering' }
// }If the adapter does not implement getSubjectScopedRoles() (it is optional on the
SubjectStore interface), the scopedRoles array will be empty. Adapters without
multi-tenant support can safely omit this method.
The Decision Object
Every authorization check returns a Decision:
interface Decision {
allowed: boolean // The final yes/no answer.
effect: 'allow' | 'deny'
rule?: Rule // The rule that determined the decision, if any.
policy?: string // The policy ID that determined the decision.
reason: string // Human-readable explanation.
duration: number // Evaluation time in milliseconds.
timestamp: number // When the decision was made (Date.now()).
}interface Decision {
allowed: boolean // The final yes/no answer.
effect: 'allow' | 'deny'
rule?: Rule // The rule that determined the decision, if any.
policy?: string // The policy ID that determined the decision.
reason: string // Human-readable explanation.
duration: number // Evaluation time in milliseconds.
timestamp: number // When the decision was made (Date.now()).
}Example decision values:
// 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 because 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,
}// 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 because 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,
}Hooks
Hooks let you intercept and observe the evaluation lifecycle. All hooks are optional and can be synchronous or async.
Note: engine.explain() only triggers beforeEvaluate. The afterEvaluate, onDeny, and onError hooks are not called -- explain is a read-only diagnostic tool.
interface EngineHooks {
beforeEvaluate?(request: AccessRequest): AccessRequest | Promise<AccessRequest>
afterEvaluate?(request: AccessRequest, decision: Decision): void | Promise<void>
onDeny?(request: AccessRequest, decision: Decision): void | Promise<void>
onError?(error: Error, request: AccessRequest): void | Promise<void>
}interface EngineHooks {
beforeEvaluate?(request: AccessRequest): AccessRequest | Promise<AccessRequest>
afterEvaluate?(request: AccessRequest, decision: Decision): void | Promise<void>
onDeny?(request: AccessRequest, decision: Decision): void | Promise<void>
onError?(error: Error, request: AccessRequest): void | Promise<void>
}beforeEvaluate
Runs before the evaluation. Receives the request and must return a (possibly modified) request. Use it to enrich the request with computed context.
hooks: {
beforeEvaluate: (request) => {
// Add current time so time-based conditions work
return {
...request,
environment: {
...request.environment,
timestamp: Date.now(),
dayOfWeek: new Date().getDay(),
},
}
},
}hooks: {
beforeEvaluate: (request) => {
// Add current time so time-based conditions work
return {
...request,
environment: {
...request.environment,
timestamp: Date.now(),
dayOfWeek: new Date().getDay(),
},
}
},
}afterEvaluate
Runs after every evaluation. Use it for logging and auditing.
hooks: {
afterEvaluate: async (request, decision) => {
await db.insert(accessLog).values({
subjectId: request.subject.id,
action: request.action,
resource: request.resource.type,
resourceId: request.resource.id,
allowed: decision.allowed,
reason: decision.reason,
duration: decision.duration,
timestamp: new Date(decision.timestamp),
})
},
}hooks: {
afterEvaluate: async (request, decision) => {
await db.insert(accessLog).values({
subjectId: request.subject.id,
action: request.action,
resource: request.resource.type,
resourceId: request.resource.id,
allowed: decision.allowed,
reason: decision.reason,
duration: decision.duration,
timestamp: new Date(decision.timestamp),
})
},
}onDeny
Runs only when a request is denied. Use it for alerting and security monitoring.
hooks: {
onDeny: async (request, decision) => {
metrics.increment('access.denied', {
action: request.action,
resource: request.resource.type,
})
// Alert on repeated denials from the same subject
const recentDenials = await getRecentDenials(request.subject.id)
if (recentDenials > 10) {
await alertSecurityTeam(request.subject.id)
}
},
}hooks: {
onDeny: async (request, decision) => {
metrics.increment('access.denied', {
action: request.action,
resource: request.resource.type,
})
// Alert on repeated denials from the same subject
const recentDenials = await getRecentDenials(request.subject.id)
if (recentDenials > 10) {
await alertSecurityTeam(request.subject.id)
}
},
}onError
Runs when the evaluation throws an error. Use it for error recovery and reporting. When an error occurs, the engine automatically returns a deny decision with the error message as the reason.
hooks: {
onError: (error, request) => {
sentry.captureException(error, {
extra: {
subjectId: request.subject.id,
action: request.action,
resource: request.resource.type,
},
})
},
}hooks: {
onError: (error, request) => {
sentry.captureException(error, {
extra: {
subjectId: request.subject.id,
action: request.action,
resource: request.resource.type,
},
})
},
}Hook behavior details
- Hook errors: if a hook itself throws, the engine catches it and returns a deny
decision. A failing
beforeEvaluatehook will abort the evaluation. A failingafterEvaluateoronDenyhook will not change the decision already made. - Execution order for
authorize()/can()/check():beforeEvaluate-> evaluate ->afterEvaluate->onDeny(if denied) ->onError(on exception). permissions()batch checks: each individual check in the batch runs through the full hook pipeline (beforeEvaluate, afterEvaluate, onDeny). Scoped roles are enriched per-check since each check can have a different scope.explain()traces:beforeEvaluateis applied, butafterEvaluate,onDeny, andonErrorare NOT triggered. Explain is a read-only diagnostic tool.
Cache Management
The engine maintains four LRU caches:
The engine maintains four LRU caches:
| Cache | Key | Stores | Purpose |
|---|---|---|---|
| Policy cache | 'all' | All ABAC policies | Avoid re-fetching policies on every check |
| Role cache | 'all' | All role definitions | Avoid re-fetching roles on every check |
| RBAC policy cache | 'rbac' | The synthetic RBAC policy | Avoid recomputing role-to-policy conversion |
| Subject cache | subject ID | Resolved subjects | Avoid re-resolving the same user repeatedly |
Invalidation methods
// Clear everything
engine.invalidate()
// Clear a specific user's cached data (after role change, attribute update)
engine.invalidateSubject('user-1')
// Clear cached policies (after adding/removing/editing policies)
engine.invalidatePolicies()
// Clear cached roles and all subjects (subjects depend on roles)
engine.invalidateRoles()// Clear everything
engine.invalidate()
// Clear a specific user's cached data (after role change, attribute update)
engine.invalidateSubject('user-1')
// Clear cached policies (after adding/removing/editing policies)
engine.invalidatePolicies()
// Clear cached roles and all subjects (subjects depend on roles)
engine.invalidateRoles()When to invalidate
The admin API methods automatically invalidate the relevant caches:
admin.savePolicy()/admin.deletePolicy()-- invalidates policies.admin.saveRole()/admin.deleteRole()-- invalidates roles + subjects.admin.assignRole()/admin.revokeRole()-- invalidates the specific subject.admin.setAttributes()-- invalidates the specific subject.
You only need to call invalidation methods manually if you modify data outside the admin API (e.g., direct database writes).
Tuning the cache
For high-traffic applications:
const engine = new Engine({
adapter,
cacheTTL: 300, // 5 minutes -- suitable for policies/roles that change infrequently
maxCacheSize: 10000, // 10k subjects in memory
})const engine = new Engine({
adapter,
cacheTTL: 300, // 5 minutes -- suitable for policies/roles that change infrequently
maxCacheSize: 10000, // 10k subjects in memory
})For real-time permission changes:
const engine = new Engine({
adapter,
cacheTTL: 5, // 5 seconds -- near real-time
maxCacheSize: 500,
})const engine = new Engine({
adapter,
cacheTTL: 5, // 5 seconds -- near real-time
maxCacheSize: 500,
})Admin API
The engine.admin property exposes CRUD operations for policies, roles, and subject
attributes. All mutations automatically invalidate the relevant caches.
Policy management
// List all policies
const policies = await engine.admin.listPolicies()
// Get a specific policy
const policy = await engine.admin.getPolicy('ip-restriction')
// Save (create or update) a policy
await engine.admin.savePolicy({
id: 'office-hours',
name: 'Office Hours Only',
algorithm: 'deny-overrides',
rules: [
{
id: 'deny-outside-hours',
effect: 'deny',
priority: 100,
actions: ['*'],
resources: ['*'],
conditions: {
any: [
{ field: 'environment.hour', operator: 'lt', value: 9 },
{ field: 'environment.hour', operator: 'gt', value: 17 },
],
},
},
{
id: 'allow-all',
effect: 'allow',
priority: 1,
actions: ['*'],
resources: ['*'],
conditions: { all: [] },
},
],
})
// Delete a policy
await engine.admin.deletePolicy('office-hours')// List all policies
const policies = await engine.admin.listPolicies()
// Get a specific policy
const policy = await engine.admin.getPolicy('ip-restriction')
// Save (create or update) a policy
await engine.admin.savePolicy({
id: 'office-hours',
name: 'Office Hours Only',
algorithm: 'deny-overrides',
rules: [
{
id: 'deny-outside-hours',
effect: 'deny',
priority: 100,
actions: ['*'],
resources: ['*'],
conditions: {
any: [
{ field: 'environment.hour', operator: 'lt', value: 9 },
{ field: 'environment.hour', operator: 'gt', value: 17 },
],
},
},
{
id: 'allow-all',
effect: 'allow',
priority: 1,
actions: ['*'],
resources: ['*'],
conditions: { all: [] },
},
],
})
// Delete a policy
await engine.admin.deletePolicy('office-hours')Role management
// List all roles
const roles = await engine.admin.listRoles()
// Get a specific role
const role = await engine.admin.getRole('editor')
// Save (create or update) a role
await engine.admin.saveRole({
id: 'moderator',
name: 'Moderator',
permissions: [
{ action: 'read', resource: 'post' },
{ action: 'update', resource: 'post' },
{ action: 'delete', resource: 'comment' },
],
inherits: ['viewer'],
})
// Delete a role
await engine.admin.deleteRole('moderator')// List all roles
const roles = await engine.admin.listRoles()
// Get a specific role
const role = await engine.admin.getRole('editor')
// Save (create or update) a role
await engine.admin.saveRole({
id: 'moderator',
name: 'Moderator',
permissions: [
{ action: 'read', resource: 'post' },
{ action: 'update', resource: 'post' },
{ action: 'delete', resource: 'comment' },
],
inherits: ['viewer'],
})
// Delete a role
await engine.admin.deleteRole('moderator')Role assignments
// Assign a role to a user
await engine.admin.assignRole('user-1', 'editor')
// Assign a scoped role (multi-tenant)
await engine.admin.assignRole('user-1', 'admin', 'org-1')
// Revoke a role
await engine.admin.revokeRole('user-1', 'editor')
// Revoke a scoped role
await engine.admin.revokeRole('user-1', 'admin', 'org-1')// Assign a role to a user
await engine.admin.assignRole('user-1', 'editor')
// Assign a scoped role (multi-tenant)
await engine.admin.assignRole('user-1', 'admin', 'org-1')
// Revoke a role
await engine.admin.revokeRole('user-1', 'editor')
// Revoke a scoped role
await engine.admin.revokeRole('user-1', 'admin', 'org-1')Subject attributes
// Set attributes (MemoryAdapter merges with existing; other adapters may replace)
await engine.admin.setAttributes('user-1', {
department: 'engineering',
level: 'senior',
region: 'us-east',
})
// Read attributes
const attrs = await engine.admin.getAttributes('user-1')
// { department: 'engineering', level: 'senior', region: 'us-east' }// Set attributes (MemoryAdapter merges with existing; other adapters may replace)
await engine.admin.setAttributes('user-1', {
department: 'engineering',
level: 'senior',
region: 'us-east',
})
// Read attributes
const attrs = await engine.admin.getAttributes('user-1')
// { department: 'engineering', level: 'senior', region: 'us-east' }The exact merge/replace behavior depends on the adapter implementation. The MemoryAdapter
shallow-merges new attributes into existing ones. The PrismaAdapter and DrizzleAdapter
also merge. If you need to remove an attribute, set it to null.
Putting It All Together
Here is a complete example that sets up an engine with hooks, uses the admin API to manage roles and policies, and runs permission checks:
import { defineRole, Engine, MemoryAdapter } from '@gentleduck/iam'
// Define roles
const viewer = defineRole('viewer')
.grant('read', 'post')
.grant('read', 'comment')
.build()
const editor = defineRole('editor')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.build()
// Create engine
const adapter = new MemoryAdapter({
roles: [viewer, editor],
assignments: { 'user-1': ['editor'] },
})
const engine = new Engine({
adapter,
defaultEffect: 'deny',
cacheTTL: 60,
hooks: {
afterEvaluate: (req, decision) => {
console.log(`${req.subject.id} -> ${req.action}:${req.resource.type} = ${decision.effect}`)
},
},
})
// Check permissions
await engine.can('user-1', 'read', { type: 'post', attributes: {} })
// -> true
// Add a new role at runtime
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 (admin has wildcard)import { defineRole, Engine, MemoryAdapter } from '@gentleduck/iam'
// Define roles
const viewer = defineRole('viewer')
.grant('read', 'post')
.grant('read', 'comment')
.build()
const editor = defineRole('editor')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.build()
// Create engine
const adapter = new MemoryAdapter({
roles: [viewer, editor],
assignments: { 'user-1': ['editor'] },
})
const engine = new Engine({
adapter,
defaultEffect: 'deny',
cacheTTL: 60,
hooks: {
afterEvaluate: (req, decision) => {
console.log(`${req.subject.id} -> ${req.action}:${req.resource.type} = ${decision.effect}`)
},
},
})
// Check permissions
await engine.can('user-1', 'read', { type: 'post', attributes: {} })
// -> true
// Add a new role at runtime
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 (admin has wildcard)