Skip to main content

Troubleshooting

Common errors, what causes them, and how to fix them. Plus the right knobs for diagnosing your own conditions.

Diagnostic toolbox

When a decision surprises you, reach for these tools in order:

  1. engine.explain() - returns the full evaluation trace: which policy matched, which rule, which condition passed or failed, and why. Dev mode only. The single most useful tool for debugging.
  2. validateRoles() + validatePolicy() - emit typed IamValidate.IIssue objects with closed-set codes. Run at boot.
  3. engine.stats.get() - counters for cache hits, evaluations, errors, denies.
  4. engine.healthCheck() - pings the adapter, reports staleness.
  5. engine.preload() - eagerly resolve roles and policies (catches adapter errors before the first user request).

Always start with explain(). If a check returns the wrong decision, nine times out of ten the explain trace shows exactly which condition fired.

Common errors

"My role's permissions don't apply"

Symptoms: engine.can({ subject: { roles: ['editor'] }, action: 'update', resource: 'post' }) returns false, but editor should be allowed.

Causes & fixes:

  • The role isn't loaded. Check await engine.preload() runs before the call and the adapter actually returns the role.
  • The role name has a typo. defineRole('editor') then assign 'edtior' - the type check would normally catch this; if you're using as casts, you've defeated the safety net.
  • Inheritance is broken. editor.inherits('viewer') requires viewer to exist in the adapter. Missing inheritance silently no-ops.
  • The action / resource isn't declared in defineIam. The config is the closed set; arbitrary strings won't trip rules.
  • A deny policy is shadowing the allow. Run explain() and look at the cross-policy combination decision.

"Policy conditions never match"

Symptoms: A policy with condition: when().equals('subject.id', '$resource.attributes.ownerId') always returns deny.

Causes & fixes:

  • The $resource path is wrong. Run explain() and check the conditionContext snapshot - it shows the actual values that were compared.
  • The resource was passed as a string, not an object. engine.can({ resource: 'post' }) binds resource.type = 'post' and resource.attributes = {}. Conditions reading $resource.attributes.ownerId then see undefined. Pass { type: 'post', attributes: post } instead.
  • The condition compares the wrong type. when().equals('subject.id', '$resource.attributes.ownerId') with subject.id: 'user-1' and resource.attributes.ownerId: 1 (a number) returns false even though they look the same. iam compares values with strict equality, no coercion.

"Type error: Argument of type 'X' is not assignable"

Symptoms: engine.can({ action: 'delete', resource: 'post' }) flags a TS error.

Causes & fixes:

  • The action or resource isn't in your defineIam. Add it to the const-asserted array.
  • You're using engine.can without a typed engine. Construct via defineIam(...).createEngine({ ... }) to propagate types.
  • You're passing dynamic values that lose their literal type. Mark them as const or assert against your config's union types.

"POLICY_LIMIT_EXCEEDED"

Symptoms: validatePolicy() returns { code: 'POLICY_LIMIT_EXCEEDED' }.

Cause: A single policy exceeds IAM_POLICY_LIMITS (number of rules, actions per rule, resources per rule). The defaults are generous (50 rules, 100 actions, 100 resources). If you've hit them, the policy is likely a "god policy" that mixes too many domains - split it.

"MAX_INHERITANCE_DEPTH_EXCEEDED"

Symptoms: validateRoles() returns this code.

Cause: A role's inherits chain is longer than MAX_INHERITANCE_DEPTH (default 16). Often a sign of accidental cyclic inheritance - admin -> editor -> admin - that gets unrolled past the limit.

Fix: Run validateRoles() and look at the cycle field; remove the offending edge.

Performance debugging

"Checks are slow"

engine.stats.get() shows you cache hits vs misses. If the miss rate is high, raise the LRU sizes:

createEngine({
  cache: {
    policy: { max: 1000 },     // default 100
    role: { max: 1000 },       // default 100
    subject: { max: 10_000 },  // default 1000
    ttlMs: 60_000,             // default 30s
  },
})

If engine.stats.get().errors is non-zero, look at the adapter logs - an intermittent network error can fall through to fail-open by default; opt in to failOpen: false for stricter behaviour.

"Cross-instance cache drift"

If two replicas disagree on a permission check because one's cache is stale, wire an invalidator:

import { IamRedisInvalidator } from '@gentleduck/iam/integrations/invalidators/redis'

createEngine({
  invalidator: new RedisInvalidator(redis, { channel: 'iam:invalidate' }),
})

Every mutation publishes an invalidation event; every replica clears the affected cache entries.

See Redis invalidator ->.

Production gotchas

  • Don't forget await engine.preload() at boot, or your first user request pays the full adapter cold-start.
  • Use production mode for high-throughput. Dev mode returns rich decision objects (alloc-heavy); production mode returns plain booleans. See Development vs production mode and Benchmarks.
  • Wire onError and onPolicyError so silent adapter failures don't lock everyone out without a trace.
  • Test your policies. The engine ships an engine.test() helper (dev only) that asserts decisions against expected outcomes - pair with vitest for regression tests.

Getting help