Skip to main content

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.

Loading diagram...

Engine Methods Overview

MethodReturns (dev)Returns (prod)Description
engine.can(subjectId, action, resource, env?, scope?)booleanbooleanSimple yes/no permission check
engine.check(subjectId, action, resource, env?, scope?)DecisionbooleanFull decision in dev, fast boolean in prod
engine.authorize(request)DecisionDecisionLow-level: takes a full AccessRequest
engine.permissions(subjectId, checks, env?)PermissionMapRecord<string, boolean>Batch check multiple permissions
engine.explain(subjectId, action, resource, env?, scope?)ExplainResultn/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

MethodDevelopment (default)Production
can()booleanboolean
check()Decision (with reason, timing, effect)boolean
permissions()PermissionMap (values are Decision)Record<string, boolean>
explain()ExplainResultnot 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 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 mode

Production 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.

Loading diagram...

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:

src/access.ts
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,
          },
        },
      }
    },
  },
})
src/access.ts
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:

Loading diagram...

How Caching Works

  1. Policy cache (1 entry): stores the result of adapter.listPolicies(). All policies are loaded once and cached together.
  2. Role cache (1 entry): stores the result of adapter.listRoles(). All roles are loaded once and cached together.
  3. RBAC policy cache (1 entry): stores the __rbac__ policy generated from roles. Regenerated only when the role cache is invalidated.
  4. Subject cache (up to maxCacheSize entries): 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: 0 disables 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 caches
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 caches

invalidateRoles() 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

FormatExampleWhen
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

MethodDescriptionCache Invalidation
listPolicies()List all policiesnone
getPolicy(id)Get a single policynone
savePolicy(policy)Create or update a policyclears policy cache
deletePolicy(id)Delete a policyclears policy cache
listRoles()List all rolesnone
getRole(id)Get a single rolenone
saveRole(role)Create or update a roleclears role + RBAC + subject caches
deleteRole(id)Delete a roleclears role + RBAC + subject caches
assignRole(subjectId, roleId, scope?)Assign a role to a subjectclears that subject's cache
revokeRole(subjectId, roleId, scope?)Revoke a role from a subjectclears that subject's cache
setAttributes(subjectId, attrs)Set subject attributesclears that subject's cache
getAttributes(subjectId)Get subject attributesnone

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.

Checkpoint: Complete Engine Setup


Chapter 4 FAQ


Next: Chapter 5: Multi-Tenant Scoping