chapter 4: the engine in depth
Master the engine's methods, hooks, caching, batch permissions, explain/debug, and Admin API to 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 for performance, batch permission checks for UIs, the explain
API for debugging, and the Admin API for runtime management.
Engine Methods Overview
The engine provides five core methods:
| Method | Returns | Description |
|---|---|---|
engine.can(subjectId, action, resource, env?, scope?) | boolean | Simple yes/no permission check |
engine.check(subjectId, action, resource, env?, scope?) | Decision | Full decision with reason, timing |
engine.authorize(request) | Decision | Low-level: takes a full AccessRequest |
engine.permissions(subjectId, checks, env?) | PermissionMap | Batch check multiple permissions |
engine.explain(subjectId, action, resource, env?, scope?) | ExplainResult | Debug trace showing every evaluation step |
engine.can() and engine.check()
These are the convenience methods you will use most:
// 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(), they call resolveSubject() to load the subject
from the adapter, then call authorize() internally. Use authorize() directly when
you manage subject resolution yourself.
engine.resolveSubject()
Loads a subject's roles and attributes from the adapter:
const subject = await engine.resolveSubject('bob')
// {
// id: 'bob',
// roles: ['editor', 'viewer'], // includes inherited roles
// scopedRoles: [{ role: 'admin', scope: 'acme' }],
// attributes: { department: 'engineering' },
// }const subject = await engine.resolveSubject('bob')
// {
// id: 'bob',
// roles: ['editor', 'viewer'], // includes inherited roles
// scopedRoles: [{ role: 'admin', scope: 'acme' }],
// attributes: { department: 'engineering' },
// }The result is cached. Subsequent calls for the same subject return the cached version until the cache TTL expires or you manually invalidate.
Hooks
Hooks let you run code 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 the 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,
},
},
}
},
},
})Now callers do not 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.
Important: if beforeEvaluate throws, evaluation is skipped and the result is
deny (fail closed). The onError hook is called.
afterEvaluate -- audit logging
Log every permission check for compliance:
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 the outcome (allow or deny). It receives both
the request and the decision. Use it for audit trails, metrics, and analytics.
onDeny -- alert on denied access
Track denied requests for security monitoring:
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. It runs 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 a deny decision. This is fail-closed behavior -- 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).
This means your audit log captures every individual check.
Hooks in Explain
engine.explain() does not trigger afterEvaluate, onDeny, or onError hooks.
It only triggers beforeEvaluate (because the hook may modify the request, which affects
the evaluation trace). This makes explain() safe to call in debug tooling without side
effects in your audit logs.
Caching
The engine maintains four LRU (Least Recently Used) 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 -- the least recently accessed subject is dropped when a new one is added.
All caches use TTL (time-to-live). 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 -- they store 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, you often need to check 10-20 permissions at once to decide what buttons to show.
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
Each element in the checks array is a PermissionCheck:
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
The returned map uses colon-separated keys:
| 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() multiple times because:
- Subject data is loaded once (not per-check)
- All policies are loaded once
- The adapter is queried once, not N times
Each individual check still goes through the full evaluation pipeline including hooks.
If any check throws, it defaults to false and the onError hook is called.
Environment in Batch
You can pass an environment that applies to all checks:
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
The explain result contains the complete evaluation trace:
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
Each policy in the trace shows all its rules and whether they matched:
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
The deepest level shows exactly what each condition expected vs what it got:
// 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)[]
}This lets you see exactly which condition failed, what value was expected, and what value was actually present. Invaluable for debugging complex policies.
Explain Does Not Short-Circuit
Unlike normal evaluation, explain() evaluates all rules in all policies, even
after a deny is found. This gives you the complete picture. 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
permission check picks up the new role. You never need to manually invalidate after admin
operations.
Checkpoint: Complete Engine Setup
Full engine with all features
import { Engine, validateRoles } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { viewer, editor, admin } from './roles'
import { ownerPolicy } from './policies'
validateRoles([viewer, editor, admin])
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: { alice: ['viewer'], bob: ['editor'], charlie: ['admin'] },
policies: [ownerPolicy],
})
export const engine = new Engine({
adapter,
defaultEffect: 'deny',
cacheTTL: 60,
maxCacheSize: 1000,
hooks: {
beforeEvaluate: async (request) => {
// Enrich with DB data
if (request.resource.type === 'post' && request.resource.id) {
const post = await db.posts.findUnique({ where: { id: request.resource.id } })
return {
...request,
resource: {
...request.resource,
attributes: { ...request.resource.attributes, ownerId: post?.authorId },
},
}
}
return request
},
afterEvaluate: async (request, decision) => {
console.log(`[audit] ${request.subject.id} ${decision.effect} ${request.action}:${request.resource.type}`)
},
onDeny: async (request, decision) => {
console.log(`[denied] ${request.subject.id} -> ${request.action}:${request.resource.type}: ${decision.reason}`)
},
onError: async (error) => {
console.error('[auth-error]', error)
},
},
})import { Engine, validateRoles } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { viewer, editor, admin } from './roles'
import { ownerPolicy } from './policies'
validateRoles([viewer, editor, admin])
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: { alice: ['viewer'], bob: ['editor'], charlie: ['admin'] },
policies: [ownerPolicy],
})
export const engine = new Engine({
adapter,
defaultEffect: 'deny',
cacheTTL: 60,
maxCacheSize: 1000,
hooks: {
beforeEvaluate: async (request) => {
// Enrich with DB data
if (request.resource.type === 'post' && request.resource.id) {
const post = await db.posts.findUnique({ where: { id: request.resource.id } })
return {
...request,
resource: {
...request.resource,
attributes: { ...request.resource.attributes, ownerId: post?.authorId },
},
}
}
return request
},
afterEvaluate: async (request, decision) => {
console.log(`[audit] ${request.subject.id} ${decision.effect} ${request.action}:${request.resource.type}`)
},
onDeny: async (request, decision) => {
console.log(`[denied] ${request.subject.id} -> ${request.action}:${request.resource.type}: ${decision.reason}`)
},
onError: async (error) => {
console.error('[auth-error]', error)
},
},
})Chapter 4 FAQ
In what order do hooks run?
beforeEvaluate runs first (before evaluation), afterEvaluate runs after the decision
is made, onDeny runs only if denied (after afterEvaluate), and onError runs if any
error occurs. If beforeEvaluate throws, evaluation is skipped and the result is deny.
All hooks are async-safe.
What if the cache serves stale data?
The cache has a TTL (default 60 seconds). After that, the next check refreshes the data
from the adapter. If you need immediate consistency after a change, either use the Admin
API (which auto-invalidates) or call engine.invalidateSubject(id) manually. For tests,
set cacheTTL: 0 to disable caching entirely.
Can I use explain() in production?
Yes. explain() does not trigger side-effect hooks (afterEvaluate, onDeny, onError),
so it will not pollute your audit logs. However, it evaluates ALL rules without
short-circuiting, so it is slightly more expensive than can(). Use it for debug
endpoints or admin tooling, not for every request.
Should the Admin API be exposed to users?
No. The Admin API should only be accessible to administrators or internal services.
Protect it with its own authorization check (e.g., only users with the admin role
can call admin endpoints). Never expose savePolicy() or assignRole() to untrusted
users without validation.
Can engine.permissions() use scopes?
Yes. Each check in the array can include an optional scope field:
{ action: 'manage', resource: 'user', scope: 'acme' }. The key in the returned map
includes the scope: 'acme:manage:user': true. Each check is evaluated with its own
scope, so you can mix scoped and unscoped checks in the same batch.
When should I use authorize() instead of can()?
Use authorize() when you already have a resolved Subject object (from
resolveSubject() or your own data source) and want to skip the adapter lookup.
This is useful in middleware where you resolve the user once and check multiple
permissions, or in tests where you construct subjects manually.
Should I ever change defaultEffect from 'deny'?
Almost never. The default 'deny' means unmatched requests are denied (fail-closed).
Changing to 'allow' means any request that does not match a rule is allowed, which is
a significant security risk. The only valid use case is in development environments where
you want to log denials without blocking requests.
Why does invalidateRoles() also clear the subject cache?
Because subjects cache their resolved roles (including inherited ones). If you change
a role definition (e.g., add a permission to editor), all cached subjects with that
role have stale data. invalidateRoles() clears the role cache, the RBAC policy cache,
and all subject caches to ensure consistency.