Skip to main content
Search...

explain and debug

Use engine.explain() to trace every policy, rule, and condition evaluation. Validate policies and roles to catch config mistakes before they reach production.

Overview

When a permission check returns an unexpected result, you need to understand why. duck-iam provides two categories of debugging tools:

Loading diagram...

  • engine.explain() -- Returns a full evaluation trace showing every policy, every rule, every condition, and exactly which ones matched or failed.
  • validatePolicy() / validateRoles() -- Runtime validation for untrusted or dynamic configuration data.

engine.explain()

The explain() method runs the same evaluation as check() but traces every step without short-circuiting. Even if the first policy denies access, the engine still evaluates all remaining policies so you can see the complete picture.

explain() is a read-only diagnostic tool. It applies the beforeEvaluate hook (so enriched requests are traced accurately), but it does not trigger afterEvaluate, onDeny, or onError hooks. This means you can call it freely in debug tooling without generating audit logs or alerts.

const result = await engine.explain('user-1', 'delete', {
  type: 'post',
  id: 'post-1',
  attributes: { ownerId: 'user-2' },
})
const result = await engine.explain('user-1', 'delete', {
  type: 'post',
  id: 'post-1',
  attributes: { ownerId: 'user-2' },
})

The ExplainResult

interface ExplainResult {
  decision: Decision               // The final allow/deny decision
  request: {
    action: string                 // The action that was checked
    resourceType: string           // The resource type
    resourceId?: string            // The resource ID, if provided
    scope?: string                 // The scope, if provided
  }
  subject: {
    id: string                     // The subject ID
    roles: string[]                // The subject's base roles
    scopedRolesApplied: string[]   // Extra roles added from scoped assignments
    attributes: Record<string, any>
  }
  policies: PolicyTrace[]          // Trace of every policy evaluated
  summary: string                  // Human-readable multi-line summary
}
interface ExplainResult {
  decision: Decision               // The final allow/deny decision
  request: {
    action: string                 // The action that was checked
    resourceType: string           // The resource type
    resourceId?: string            // The resource ID, if provided
    scope?: string                 // The scope, if provided
  }
  subject: {
    id: string                     // The subject ID
    roles: string[]                // The subject's base roles
    scopedRolesApplied: string[]   // Extra roles added from scoped assignments
    attributes: Record<string, any>
  }
  policies: PolicyTrace[]          // Trace of every policy evaluated
  summary: string                  // Human-readable multi-line summary
}

The summary string

The quickest way to debug is to print the summary:

console.log(result.summary)
console.log(result.summary)

Output:

DENIED: "user-1" -> delete on post
  Roles: [editor, viewer]
  __rbac__ [allow-overrides]: No matching rules -> deny (0/5 rules evaluated)
  owner-policy [deny-overrides]: Denied by rule "deny-non-owner-delete" (1/2 rules matched)
  Result: Denied by rule "deny-non-owner-delete"
DENIED: "user-1" -> delete on post
  Roles: [editor, viewer]
  __rbac__ [allow-overrides]: No matching rules -> deny (0/5 rules evaluated)
  owner-policy [deny-overrides]: Denied by rule "deny-non-owner-delete" (1/2 rules matched)
  Result: Denied by rule "deny-non-owner-delete"

This tells you:

  • The final result was DENIED.
  • The subject has roles editor and viewer.
  • The RBAC policy had 5 rules but none matched the delete action.
  • The owner-policy matched, and rule deny-non-owner-delete produced the deny.

Reading PolicyTrace

Each entry in result.policies is a PolicyTrace:

interface PolicyTrace {
  policyId: string              // Policy identifier
  policyName: string            // Human-readable policy name
  algorithm: CombiningAlgorithm // deny-overrides, allow-overrides, etc.
  targetMatch: boolean          // Did the policy's target filter match?
  rules: RuleTrace[]            // Trace of every rule in this policy
  result: 'allow' | 'deny'     // The policy's final per-policy result
  reason: string                // Human-readable explanation
  decidingRuleId?: string       // Which rule determined the result
}
interface PolicyTrace {
  policyId: string              // Policy identifier
  policyName: string            // Human-readable policy name
  algorithm: CombiningAlgorithm // deny-overrides, allow-overrides, etc.
  targetMatch: boolean          // Did the policy's target filter match?
  rules: RuleTrace[]            // Trace of every rule in this policy
  result: 'allow' | 'deny'     // The policy's final per-policy result
  reason: string                // Human-readable explanation
  decidingRuleId?: string       // Which rule determined the result
}

When targetMatch is false, the policy was skipped entirely because its target filter (actions, resources, or roles) did not match the request. The rules array will be empty.

for (const pt of result.policies) {
  if (!pt.targetMatch) {
    console.log(`${pt.policyId}: skipped (targets don't match)`)
    continue
  }
 
  const matched = pt.rules.filter((r) => r.matched)
  console.log(`${pt.policyId} [${pt.algorithm}]: ${matched.length}/${pt.rules.length} rules matched`)
  console.log(`  Result: ${pt.result} -- ${pt.reason}`)
}
for (const pt of result.policies) {
  if (!pt.targetMatch) {
    console.log(`${pt.policyId}: skipped (targets don't match)`)
    continue
  }
 
  const matched = pt.rules.filter((r) => r.matched)
  console.log(`${pt.policyId} [${pt.algorithm}]: ${matched.length}/${pt.rules.length} rules matched`)
  console.log(`  Result: ${pt.result} -- ${pt.reason}`)
}

Reading RuleTrace

Each rule inside a policy produces a RuleTrace:

interface RuleTrace {
  ruleId: string
  description?: string
  effect: 'allow' | 'deny'
  priority: number
  actionMatch: boolean       // Did the rule's actions match the request action?
  resourceMatch: boolean     // Did the rule's resources match the request resource?
  conditionsMet: boolean     // Did all conditions evaluate to true?
  conditions: ConditionGroupTrace  // Full condition tree trace
  matched: boolean           // actionMatch && resourceMatch && conditionsMet
}
interface RuleTrace {
  ruleId: string
  description?: string
  effect: 'allow' | 'deny'
  priority: number
  actionMatch: boolean       // Did the rule's actions match the request action?
  resourceMatch: boolean     // Did the rule's resources match the request resource?
  conditionsMet: boolean     // Did all conditions evaluate to true?
  conditions: ConditionGroupTrace  // Full condition tree trace
  matched: boolean           // actionMatch && resourceMatch && conditionsMet
}

A rule only matched if all three criteria are true: action match, resource match, and conditions met.

for (const rule of policyTrace.rules) {
  if (rule.matched) {
    console.log(`  [MATCH] ${rule.ruleId} (${rule.effect}, priority ${rule.priority})`)
  } else {
    const reasons = []
    if (!rule.actionMatch) reasons.push('action mismatch')
    if (!rule.resourceMatch) reasons.push('resource mismatch')
    if (!rule.conditionsMet) reasons.push('conditions failed')
    console.log(`  [SKIP]  ${rule.ruleId} -- ${reasons.join(', ')}`)
  }
}
for (const rule of policyTrace.rules) {
  if (rule.matched) {
    console.log(`  [MATCH] ${rule.ruleId} (${rule.effect}, priority ${rule.priority})`)
  } else {
    const reasons = []
    if (!rule.actionMatch) reasons.push('action mismatch')
    if (!rule.resourceMatch) reasons.push('resource mismatch')
    if (!rule.conditionsMet) reasons.push('conditions failed')
    console.log(`  [SKIP]  ${rule.ruleId} -- ${reasons.join(', ')}`)
  }
}

Reading ConditionTrace

Conditions form a tree of logical groups (all, any, none) with leaf conditions at the bottom. The trace preserves this structure.

// Group node
interface ConditionGroupTrace {
  type: 'group'
  logic: 'all' | 'any' | 'none'
  result: boolean
  children: Array<ConditionLeafTrace | ConditionGroupTrace>
}
 
// Leaf node
interface ConditionLeafTrace {
  type: 'condition'
  field: string           // e.g. "resource.attributes.ownerId"
  operator: string        // e.g. "eq"
  expected: any           // The value from the condition definition
  actual: any             // The value resolved from the request at runtime
  result: boolean         // Did this condition pass?
}
// Group node
interface ConditionGroupTrace {
  type: 'group'
  logic: 'all' | 'any' | 'none'
  result: boolean
  children: Array<ConditionLeafTrace | ConditionGroupTrace>
}
 
// Leaf node
interface ConditionLeafTrace {
  type: 'condition'
  field: string           // e.g. "resource.attributes.ownerId"
  operator: string        // e.g. "eq"
  expected: any           // The value from the condition definition
  actual: any             // The value resolved from the request at runtime
  result: boolean         // Did this condition pass?
}

Walk the tree to find exactly which condition failed:

function printConditions(trace, indent = '') {
  if (trace.type === 'condition') {
    const mark = trace.result ? 'PASS' : 'FAIL'
    console.log(`${indent}[${mark}] ${trace.field} ${trace.operator} ${JSON.stringify(trace.expected)} (actual: ${JSON.stringify(trace.actual)})`)
  } else {
    console.log(`${indent}${trace.logic} (${trace.result ? 'PASS' : 'FAIL'}):`)
    for (const child of trace.children) {
      printConditions(child, indent + '  ')
    }
  }
}
 
// Print condition tree for a specific rule
const rule = result.policies[1].rules[0]
printConditions(rule.conditions)
function printConditions(trace, indent = '') {
  if (trace.type === 'condition') {
    const mark = trace.result ? 'PASS' : 'FAIL'
    console.log(`${indent}[${mark}] ${trace.field} ${trace.operator} ${JSON.stringify(trace.expected)} (actual: ${JSON.stringify(trace.actual)})`)
  } else {
    console.log(`${indent}${trace.logic} (${trace.result ? 'PASS' : 'FAIL'}):`)
    for (const child of trace.children) {
      printConditions(child, indent + '  ')
    }
  }
}
 
// Print condition tree for a specific rule
const rule = result.policies[1].rules[0]
printConditions(rule.conditions)

Output:

all (FAIL):
  [PASS] subject.roles contains "editor"
  [FAIL] resource.attributes.ownerId eq "user-1" (actual: "user-2")
all (FAIL):
  [PASS] subject.roles contains "editor"
  [FAIL] resource.attributes.ownerId eq "user-1" (actual: "user-2")

This immediately shows you that the subject has the editor role but the ownerId condition failed because the post belongs to user-2, not user-1.

Validation

validatePolicy()

Use validatePolicy() to validate untrusted policy objects before saving them. This is essential when policies come from a database, API, or admin dashboard where the data could be malformed.

import { validatePolicy } from '@gentleduck/iam'
 
// Policy from an external source (database, API, user input)
const policyJson = await db.query('SELECT data FROM policies WHERE id = $1', [id])
 
const result = validatePolicy(policyJson)
 
if (!result.valid) {
  const messages = result.issues.map((i) => i.message).join(', ')
  throw new Error(`Invalid policy: ${messages}`)
}
 
// Safe to use
await engine.admin.savePolicy(policyJson)
import { validatePolicy } from '@gentleduck/iam'
 
// Policy from an external source (database, API, user input)
const policyJson = await db.query('SELECT data FROM policies WHERE id = $1', [id])
 
const result = validatePolicy(policyJson)
 
if (!result.valid) {
  const messages = result.issues.map((i) => i.message).join(', ')
  throw new Error(`Invalid policy: ${messages}`)
}
 
// Safe to use
await engine.admin.savePolicy(policyJson)

What it checks:

  • Required fields: id, name, algorithm, rules
  • Valid combining algorithm (deny-overrides, allow-overrides, first-match, highest-priority)
  • Each rule has: id, effect, priority, actions, resources
  • Valid effect values (allow or deny)
  • Valid operators in conditions
  • Correct condition group structure (all/any/none with arrays)
  • Duplicate rule IDs (warning)
  • Valid targets structure if present

ValidationResult

interface ValidationResult {
  valid: boolean                   // true if no errors (warnings are ok)
  issues: ValidationIssue[]
}
 
interface ValidationIssue {
  type: 'error' | 'warning'       // errors cause valid=false, warnings don't
  code: string                    // machine-readable code like 'MISSING_FIELD'
  message: string                 // human-readable description
  roleId?: string                 // which role, if applicable
  path?: string                   // JSON path like 'rules[0].effect'
}
interface ValidationResult {
  valid: boolean                   // true if no errors (warnings are ok)
  issues: ValidationIssue[]
}
 
interface ValidationIssue {
  type: 'error' | 'warning'       // errors cause valid=false, warnings don't
  code: string                    // machine-readable code like 'MISSING_FIELD'
  message: string                 // human-readable description
  roleId?: string                 // which role, if applicable
  path?: string                   // JSON path like 'rules[0].effect'
}

validateRoles()

Use validateRoles() to validate your role configuration. This catches structural mistakes that would cause silent failures at runtime.

import { validateRoles } from '@gentleduck/iam'
 
const viewer = defineRole('viewer').grant('read', 'post').build()
const editor = defineRole('editor').inherits('viewer').grant('update', 'post').build()
const admin = defineRole('admin').inherits('editor').grant('delete', 'post').build()
 
const result = validateRoles([viewer, editor, admin])
 
if (!result.valid) {
  throw new Error('Role configuration error: ' + result.issues.map((i) => i.message).join(', '))
}
import { validateRoles } from '@gentleduck/iam'
 
const viewer = defineRole('viewer').grant('read', 'post').build()
const editor = defineRole('editor').inherits('viewer').grant('update', 'post').build()
const admin = defineRole('admin').inherits('editor').grant('delete', 'post').build()
 
const result = validateRoles([viewer, editor, admin])
 
if (!result.valid) {
  throw new Error('Role configuration error: ' + result.issues.map((i) => i.message).join(', '))
}

What it checks:

CheckSeverityExample
Duplicate role IDserrorTwo roles both named 'editor'
Dangling inheritserrorRole inherits from 'superadmin' which does not exist
Circular inheritancewarningadmin inherits editor, editor inherits admin
Empty roleswarningRole has no permissions and no inheritance

Circular inheritance is a warning (not an error) because the engine handles it gracefully at runtime by tracking visited roles during resolution.

Example: catching a dangling inherit

const editor = defineRole('editor')
  .inherits('viewer')  // "viewer" role is missing!
  .grant('update', 'post')
  .build()
 
const result = validateRoles([editor])
 
console.log(result.valid)   // false
console.log(result.issues)
// [{ type: 'error', code: 'DANGLING_INHERIT',
//    message: 'Role "editor" inherits from "viewer" which does not exist',
//    roleId: 'editor' }]
const editor = defineRole('editor')
  .inherits('viewer')  // "viewer" role is missing!
  .grant('update', 'post')
  .build()
 
const result = validateRoles([editor])
 
console.log(result.valid)   // false
console.log(result.issues)
// [{ type: 'error', code: 'DANGLING_INHERIT',
//    message: 'Role "editor" inherits from "viewer" which does not exist',
//    roleId: 'editor' }]

Example: catching circular inheritance

const a = { id: 'a', name: 'A', permissions: [], inherits: ['b'] }
const b = { id: 'b', name: 'B', permissions: [], inherits: ['a'] }
 
const result = validateRoles([a, b])
 
console.log(result.valid)   // true (circular is a warning, not an error)
console.log(result.issues)
// [{ type: 'warning', code: 'CIRCULAR_INHERIT',
//    message: 'Circular inheritance detected involving role "a" (cycle includes "a")' }]
const a = { id: 'a', name: 'A', permissions: [], inherits: ['b'] }
const b = { id: 'b', name: 'B', permissions: [], inherits: ['a'] }
 
const result = validateRoles([a, b])
 
console.log(result.valid)   // true (circular is a warning, not an error)
console.log(result.issues)
// [{ type: 'warning', code: 'CIRCULAR_INHERIT',
//    message: 'Circular inheritance detected involving role "a" (cycle includes "a")' }]

Common Debugging Scenarios

"Why was my request denied?"

Use engine.explain() and check the summary first:

const result = await engine.explain('user-1', 'update', {
  type: 'post',
  id: 'post-5',
  attributes: { ownerId: 'user-3' },
})
 
console.log(result.summary)
const result = await engine.explain('user-1', 'update', {
  type: 'post',
  id: 'post-5',
  attributes: { ownerId: 'user-3' },
})
 
console.log(result.summary)

If the summary shows "No matching rules," the subject does not have a role with the required permission. Check result.subject.roles.

If the summary shows a specific deny rule, look at that rule's conditions in the trace to see which condition failed.

"Why was my request allowed when it should be denied?"

Check if any policy has allow-overrides that is too permissive. Look through the policy traces:

const result = await engine.explain('user-1', 'delete', {
  type: 'post',
  attributes: {},
})
 
for (const pt of result.policies) {
  if (pt.result === 'allow') {
    console.log(`Policy "${pt.policyId}" allowed this:`)
    for (const rule of pt.rules.filter((r) => r.matched && r.effect === 'allow')) {
      console.log(`  Rule "${rule.ruleId}" matched`)
    }
  }
}
const result = await engine.explain('user-1', 'delete', {
  type: 'post',
  attributes: {},
})
 
for (const pt of result.policies) {
  if (pt.result === 'allow') {
    console.log(`Policy "${pt.policyId}" allowed this:`)
    for (const rule of pt.rules.filter((r) => r.matched && r.effect === 'allow')) {
      console.log(`  Rule "${rule.ruleId}" matched`)
    }
  }
}

"My scoped roles are not being applied"

Check that the scope is being passed and that the scoped roles are resolving:

const result = await engine.explain(
  'user-1',
  'manage',
  { type: 'dashboard', attributes: {} },
  undefined,  // environment
  'org-1',    // scope
)
 
console.log('Base roles:', result.subject.roles)
console.log('Scoped roles added:', result.subject.scopedRolesApplied)
const result = await engine.explain(
  'user-1',
  'manage',
  { type: 'dashboard', attributes: {} },
  undefined,  // environment
  'org-1',    // scope
)
 
console.log('Base roles:', result.subject.roles)
console.log('Scoped roles added:', result.subject.scopedRolesApplied)

If scopedRolesApplied is empty, the adapter either does not have scoped role assignments for this subject/scope combination or does not implement getSubjectScopedRoles().

"My conditions reference the wrong field path"

Use the condition trace to compare expected vs actual values:

const result = await engine.explain('user-1', 'update', {
  type: 'post',
  id: 'post-1',
  attributes: { author: 'user-1' },
})
 
// Find the failing condition
for (const pt of result.policies) {
  for (const rule of pt.rules) {
    if (!rule.conditionsMet) {
      printConditions(rule.conditions)
      // [FAIL] resource.attributes.ownerId eq "user-1" (actual: null)
      // The field is "author" not "ownerId"!
    }
  }
}
const result = await engine.explain('user-1', 'update', {
  type: 'post',
  id: 'post-1',
  attributes: { author: 'user-1' },
})
 
// Find the failing condition
for (const pt of result.policies) {
  for (const rule of pt.rules) {
    if (!rule.conditionsMet) {
      printConditions(rule.conditions)
      // [FAIL] resource.attributes.ownerId eq "user-1" (actual: null)
      // The field is "author" not "ownerId"!
    }
  }
}

"My policy from the database is being rejected"

Validate it before saving:

const policyFromDB = await fetchPolicyFromDB(policyId)
 
const result = validatePolicy(policyFromDB)
if (!result.valid) {
  for (const issue of result.issues) {
    console.error(`[${issue.type}] ${issue.path}: ${issue.message}`)
  }
  // [error] rules[2].effect: Invalid effect "Allow". Must be "allow" or "deny"
  // [error] rules[3].conditions.all[0].operator: Invalid operator "equal"
}
const policyFromDB = await fetchPolicyFromDB(policyId)
 
const result = validatePolicy(policyFromDB)
if (!result.valid) {
  for (const issue of result.issues) {
    console.error(`[${issue.type}] ${issue.path}: ${issue.message}`)
  }
  // [error] rules[2].effect: Invalid effect "Allow". Must be "allow" or "deny"
  // [error] rules[3].conditions.all[0].operator: Invalid operator "equal"
}

Common mistakes in dynamic policies:

  • Capitalized effect values ("Allow" instead of "allow")
  • Wrong operator names ("equal" instead of "eq")
  • Missing required fields (id, priority)
  • Conditions using a bare object instead of an all/any/none group