faqs
Frequently asked questions about duck-iam -- installation, concepts, roles, policies, conditions, adapters, frameworks, caching, debugging, security, and production usage.
Common questions about duck-iam organized by topic. If you cannot find what you need, open an issue or discussion on GitHub: https://github.com/gentleeduck/duck-iam
Getting Started
What is duck-iam?
duck-iam is a TypeScript authorization engine that combines Role-Based Access Control (RBAC) and
Attribute-Based Access Control (ABAC) into a single evaluation pipeline. You define roles with
permissions, write policies with conditions, and the engine evaluates them together to produce
an allow or deny decision. Everything is type-safe -- actions, resources, scopes, and role IDs
are checked at compile time when using createAccessConfig().
How does duck-iam compare to pure RBAC or pure ABAC?
Pure RBAC is simple but rigid -- you cannot express rules like "editors can only update their own
posts." Pure ABAC is powerful but verbose -- every permission requires a full policy rule, even
for basic role checks. duck-iam combines both: roles handle common patterns (viewer, editor, admin)
and policies handle fine-grained rules (owner-only access, time-based restrictions, IP filtering).
Roles are internally converted to an ABAC policy via rolesToPolicy(), so the same condition
engine handles everything in one unified pipeline.
How do I install duck-iam?
Install the package with your preferred package manager:
npm install @gentleduck/iam
duck-iam has zero runtime dependencies. The core package works in Node.js, Bun, Deno, and edge runtimes.
Why does duck-iam have zero runtime dependencies?
Zero dependencies means fewer supply-chain risks, smaller bundle sizes, and no version conflicts with your other packages. The core engine is pure TypeScript logic -- it does not need any external libraries. Database adapters (Prisma, Drizzle) use peer dependencies so the ORM version is yours to control.
Which frameworks are supported?
Server integrations:
- Express -- middleware via
@gentleduck/iam/server/express - Hono -- middleware via
@gentleduck/iam/server/hono - NestJS -- guard + decorator via
@gentleduck/iam/server/nest - Next.js -- route wrappers via
@gentleduck/iam/server/next - Generic -- framework-agnostic helpers via
@gentleduck/iam/server/generic
Client libraries:
- React -- context provider + hooks via
@gentleduck/iam/client/react - Vue -- plugin + composables via
@gentleduck/iam/client/vue - Vanilla -- framework-agnostic via
@gentleduck/iam/client/vanilla
What are all the import paths available?
duck-iam uses subpath exports so you only import what you need:
@gentleduck/iam-- core engine, builders, validators, types@gentleduck/iam/adapters/memory-- in-memory adapter@gentleduck/iam/adapters/prisma-- Prisma ORM adapter@gentleduck/iam/adapters/drizzle-- Drizzle ORM adapter@gentleduck/iam/adapters/http-- HTTP/REST adapter@gentleduck/iam/server/express-- Express middleware@gentleduck/iam/server/hono-- Hono middleware@gentleduck/iam/server/nest-- NestJS guard and decorators@gentleduck/iam/server/next-- Next.js route wrappers@gentleduck/iam/server/generic-- framework-agnostic helpers@gentleduck/iam/client/react-- React hooks + provider@gentleduck/iam/client/vue-- Vue plugin + composables@gentleduck/iam/client/vanilla-- vanilla JS/TS client
Is duck-iam production-ready?
Yes. The core engine has comprehensive test coverage (295+ tests), handles edge cases like circular
role inheritance and deeply nested conditions safely, includes built-in caching with configurable TTL,
and validates all inputs. The engine fails closed -- errors default to deny, malformed conditions
default to deny, and the default effect is deny. Always validate your role and policy configurations
at startup using validateRoles() and validatePolicy() to catch mistakes early.
What is the minimum code needed to get started?
import { defineRole, Engine } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
const viewer = defineRole('viewer').grant('read', 'post').build()
const adapter = new MemoryAdapter({
roles: [viewer],
assignments: { 'user-1': ['viewer'] },
})
const engine = new Engine({ adapter })
const result = await engine.can('user-1', 'read', { type: 'post', attributes: {} })
// result.allowed === trueThat is a working authorization check in under 15 lines. You can add policies, conditions, scopes, and more as your needs grow.
Roles and Permissions
What is a role in duck-iam?
A role is a named collection of permissions. Each permission is an action-resource pair like
read:post or delete:comment. Roles can inherit from other roles to form hierarchies.
Subjects (users) are assigned one or more roles, and the engine resolves all permissions
transitively through the inheritance chain.
How does role inheritance work?
Roles can inherit from other roles using the inherits property in defineRole().inherits('parent').
When a subject has the editor role, and editor inherits from viewer, the subject gets all
of viewer's permissions automatically. Inheritance chains are resolved transitively -- if admin
inherits editor, which inherits viewer, then admin has all three sets of permissions.
The engine uses a visited-set to prevent infinite loops from circular inheritance.
Can a role inherit from multiple parents?
Yes. A role can inherit from as many parents as you need. For example, a moderator role can
inherit from both viewer and commenter:
const moderator = defineRole('moderator')
.inherits('viewer', 'commenter')
.grant('delete', 'comment')
.build()The engine collects permissions from all ancestors. Duplicate permissions are deduplicated automatically.
What happens with circular role inheritance?
The engine handles it safely. During role resolution, it maintains a visited-set of role IDs.
If a role has already been visited, it is skipped, breaking the cycle. validateRoles() will
report circular inheritance as a warning (not an error) because the engine handles it gracefully
at runtime. However, circular inheritance is almost always a configuration mistake -- fix the
cycle when you see the warning.
Can a subject have multiple roles?
Yes. A subject can have any number of roles assigned. The engine resolves permissions from all
assigned roles (including inherited ones) and combines them into a single synthetic __rbac__
policy with allow-overrides. This means if any role grants a permission, the RBAC layer allows
it. Policies can then add further restrictions on top of that.
How are roles converted to policies internally?
The engine calls rolesToPolicy() which takes the subject's resolved roles and creates a synthetic
policy with ID __rbac__. Each permission becomes a rule with effect: 'allow' and the matching
action and resource. This policy uses allow-overrides as its combining algorithm -- if any
permission matches, the RBAC layer allows the request. This policy is then evaluated alongside
your custom ABAC policies.
Can I use wildcards in role permissions?
Yes. Use '*' to match any action or resource:
grant('*', 'post')-- all actions on postsgrant('read', '*')-- read access on all resourcesgrant('*', '*')-- full access to everything (superadmin)
Wildcards are matched during rule evaluation. A rule with actions: ['*'] matches any action
in the request.
Can a role's permissions be limited to a specific scope?
Yes. Permissions in a role can include an optional scope field. When present, that permission
only matches if the request scope matches. For example:
const orgAdmin = defineRole('org-admin')
.grant('manage', 'user', { scope: 'org-1' })
.build()This grants manage:user only when the request scope is org-1. Requests with a different
scope or no scope will not match this permission.
How do I validate my role configuration?
Call validateRoles() at application startup:
import { validateRoles } from '@gentleduck/iam'
const result = validateRoles([viewer, editor, admin])
if (!result.valid) {
throw new Error(result.issues.map(i => i.message).join(', '))
}It checks for: duplicate role IDs (error), dangling inherits referencing missing roles (error), circular inheritance (warning), and empty roles with no permissions and no inheritance (warning).
What are hierarchical resource types?
Resource types can use dot notation to form hierarchies. A type of dashboard.users matches
rules that target dashboard or dashboard.users. This means granting read on dashboard
also grants read on dashboard.users, dashboard.settings, etc. The engine checks if the
request resource type starts with the rule's resource type followed by a dot.
Policies and Rules
What is a policy?
A policy is a named container of rules with a combining algorithm. Each rule defines an effect (allow or deny), a set of actions and resources it applies to, a priority, and optional conditions. The combining algorithm determines how multiple matching rules produce a single policy-level result. Policies represent the ABAC side of duck-iam -- they can express any authorization logic that roles alone cannot handle.
When should I use a policy instead of just roles?
Use policies when you need logic that depends on attributes or context:
- Owner-only access ("users can only edit their own posts")
- Time-based restrictions ("no deployments on weekends")
- IP or location filtering ("admin panel only from office network")
- Status-based rules ("cannot edit locked or archived resources")
- Conditional denials ("deny delete unless user is admin")
If the decision only depends on "does this user have role X" then roles are sufficient. If it depends on "who owns this resource" or "what time is it" then you need a policy.
What combining algorithms are available?
Each policy has a combining algorithm that determines how its rules produce a single result:
- deny-overrides -- if any matching rule denies, the policy denies. Safest default for security-sensitive policies.
- allow-overrides -- if any matching rule allows, the policy allows. Used internally for the RBAC policy.
- first-match -- the first matching rule (by insertion order) wins. Good for ordered rule lists.
- highest-priority -- the matching rule with the highest priority number wins. Good for layered overrides.
How are multiple policies combined together?
Across policies, duck-iam uses AND-combination: all policies must allow for the overall result to be allow. A deny from any single policy means the overall result is deny. This gives you defense-in-depth -- each policy is an independent security gate. The RBAC policy (from roles) is one of these policies, so roles must grant the permission AND all custom policies must allow it.
What happens when no rules match in a policy?
The engine uses a defaultEffect which defaults to 'deny'. If no rules match in a policy,
the policy returns the default effect. Since the default is deny, this means "if nothing
explicitly allows it, it is denied." You can change the default effect per-engine by passing
defaultEffect: 'allow' in the engine options, but this is not recommended for production.
What are policy targets?
Targets are optional filters on a policy that determine whether the policy is even evaluated for a given request. A policy can target specific actions, resources, or roles:
const restrictedPolicy = policy('restricted')
.targets({ actions: ['delete'], resources: ['post'], roles: ['editor'] })
.algorithm('deny-overrides')
.rule(...)
.build()This policy is only evaluated when the action is delete, the resource is post, and the
subject has the editor role. For all other requests, the policy is skipped entirely. This
improves both performance and clarity.
What does rule priority do?
Priority is a number on each rule. Its meaning depends on the combining algorithm:
- highest-priority -- the matching rule with the highest priority number wins. This is where priority matters most.
- deny-overrides / allow-overrides -- priority is ignored; the algorithm only cares about deny or allow.
- first-match -- priority is ignored; insertion order determines which rule wins.
Higher numbers mean higher priority. A rule with priority 100 beats a rule with priority 10.
How does the engine decide if a rule matches?
A rule matches when all three checks pass:
- Action match -- the request action is in the rule's
actionsarray, or the rule has['*']. - Resource match -- the request resource type is in the rule's
resourcesarray, or the rule has['*'], or a hierarchical match (e.g., rule targetsdashboardand request isdashboard.users). - Conditions met -- all condition groups evaluate to true (or the rule has no conditions).
All three must pass. If the action matches but the resource does not, the rule is skipped.
How do I validate a policy from an external source?
Use validatePolicy() before saving or using untrusted policy data:
import { validatePolicy } from '@gentleduck/iam'
const result = validatePolicy(policyFromAPI)
if (!result.valid) {
console.error(result.issues.map(i => i.message).join(', '))
}It checks for: required fields (id, name, algorithm, rules), valid combining algorithm,
valid effect values (allow / deny), valid condition operators, correct condition group
structure, duplicate rule IDs, and valid target structure.
Can I define rules outside of a policy and reuse them?
Yes. Use defineRule() to create standalone rules:
const ownerRule = defineRule('owner-check')
.allow()
.on('update', 'delete')
.of('post')
.priority(10)
.when(w => w.isOwner())
.build()
// Add to any policy:
const p = policy('my-policy')
.algorithm('deny-overrides')
.addRule(ownerRule)
.build()This is useful when the same rule logic applies across multiple policies.
Conditions and Operators
How do conditions work?
Conditions are expressions attached to rules that must evaluate to true for the rule to match.
Each condition compares a field value from the request context against an expected value using
an operator. Fields are dot-notation paths like resource.attributes.ownerId or
environment.time. Conditions are grouped into logical blocks (all, any, none) that
can be nested.
What operators are available for conditions?
- eq -- equals (strict equality)
- neq -- not equals
- gt -- greater than
- gte -- greater than or equal
- lt -- less than
- lte -- less than or equal
- in -- value is in an array
- nin -- value is not in an array
- contains -- array contains the value
- not_contains -- array does not contain the value
- starts_with -- string starts with the value
- ends_with -- string ends with the value
- matches -- string matches a regex pattern
- exists -- field is not null/undefined
- not_exists -- field is null/undefined
- subset_of -- array is a subset of the value array
- superset_of -- array is a superset of the value array
What are condition groups (all, any, none)?
Conditions are organized into logical groups:
- all -- AND logic. Every condition in the group must pass.
- any -- OR logic. At least one condition must pass.
- none -- NOT logic. No condition may pass (all must fail).
Groups can be nested up to 10 levels deep. For example, you can have an all group containing
an any group and a none group to express: "must be (admin OR owner) AND must NOT be banned."
What are $-variables in condition values?
Values starting with $ are resolved dynamically at evaluation time from the request context:
$subject.id-- the requesting subject's ID$subject.roles-- the subject's resolved roles array$subject.attributes.department-- a subject attribute$resource.id-- the resource ID$resource.type-- the resource type$resource.attributes.ownerId-- a resource attribute$environment.time-- an environment value
Without the $ prefix, the value is treated as a static literal. With it, the engine
resolves the actual value from the request context at runtime.
How does field resolution work?
Fields in conditions are dot-notation paths resolved against the AccessRequest object. The engine splits the path by dots and walks the object tree. Available root paths:
subject.id,subject.roles,subject.attributes.*resource.type,resource.id,resource.attributes.*environment.*
If a path does not resolve (the field does not exist), the result is undefined. The engine
includes prototype pollution protection -- it blocks access to __proto__, constructor,
and prototype in field paths.
What does the isOwner() condition shortcut do?
isOwner() is a convenience method on the condition builder that expands to:
{ field: 'resource.attributes.ownerId', operator: 'eq', value: '$subject.id' }It checks if the resource's ownerId attribute equals the requesting subject's ID. You must
pass ownerId in the resource attributes when calling engine.can() or engine.check() for
this to work.
Can I check roles inside a condition?
Yes. Use the role() shortcut on the condition builder:
when(w => w.role('admin'))This expands to {"{"} field: 'subject.roles', operator: 'contains', value: 'admin' {"}"}.
You can also write it manually with .check('subject.roles', 'contains', 'admin').
How do I negate conditions?
Use the not() method on the condition builder or a none group:
// Using not() in builder:
.when(w => w
.not(w => w.role('banned'))
.isOwner()
)
// Raw condition group:
{ none: [{ field: 'subject.roles', operator: 'contains', value: 'banned' }] }none means "none of these conditions may pass" -- it is the logical NOT. If the inner
condition is true, the none group is false, and vice versa.
Can I use regex in conditions?
Yes. Use the matches operator:
.check('resource.attributes.email', 'matches', '^.*@company\.com$')The value is treated as a regex pattern string. The engine creates a RegExp from it and
tests it against the resolved field value. Be careful with regex complexity to avoid ReDoS.
What happens when a condition field resolves to undefined?
If a field path does not resolve, the actual value is undefined. Most operators will return
false when comparing against undefined (e.g., eq with a non-undefined expected value
fails). The exists operator explicitly checks for non-null/non-undefined, and not_exists
checks for null/undefined. Use exists and not_exists to safely handle optional fields.
Is there a limit on condition nesting depth?
Yes. Condition groups can be nested up to 10 levels deep. The engine enforces this limit during evaluation to prevent stack overflows from excessively deep or malicious condition trees. If you hit this limit, simplify your condition structure by flattening nested groups or splitting logic across multiple rules or policies.
Multi-Tenant and Scoping
What are scoped roles?
Scoped roles allow a subject to have different roles in different contexts (tenants, organizations,
workspaces). For example, a user can be admin in org-1 and viewer in org-2. When you pass
a scope parameter to engine.can(), the engine calls adapter.getSubjectScopedRoles() to get
the extra roles for that scope and merges them with the subject's base roles before evaluation.
What are the different levels of scoping?
duck-iam supports scoping at three levels:
- Assignment-level scoping -- A subject has different roles in different scopes. This is the most common pattern for multi-tenant apps. Handled by
getSubjectScopedRoles()in the adapter. - Permission-level scoping -- Individual permissions within a role are limited to a scope. Set via
grant('action', 'resource', {"{"} scope: 'org-1' {"}"})in the role builder. - Role-level scoping -- The entire role definition is constrained to a scope. Set via
defineRole('admin').scope('org-1').
You can combine these levels. Most applications use assignment-level scoping as it is the simplest and most flexible.
How do I pass a scope when checking permissions?
Pass the scope as the fifth parameter to engine.can() or engine.check():
await engine.can('user-1', 'manage', { type: 'dashboard', attributes: {} }, undefined, 'org-1')
// 4th param is environment (or undefined), 5th is scopeIn server middleware, the scope is typically extracted from the request (URL path, header, or JWT claim) and passed automatically.
What if my adapter does not support scoped roles?
If your adapter does not implement getSubjectScopedRoles(), the engine falls back to an empty
array -- no extra scoped roles are added. The subject's base roles are still used. You can always
add scoping support later by implementing that method on your adapter.
Can I reference the scope in policy conditions?
Yes. The scope is available in the AccessRequest context. You can write conditions that
check the scope value alongside other attributes. For example, you could create a condition
that only allows access when the resource's orgId matches the request scope, adding an
extra layer of tenant isolation on top of scoped role assignments.
Engine and Evaluation
What options can I pass to the Engine constructor?
new Engine({
adapter, // Required: the data adapter
cacheTTL: 60, // Cache time-to-live in seconds (default: 60)
maxSubjectCacheSize: 1000, // Max entries in the subject cache (default: 1000)
defaultEffect: 'deny', // Default when no rules match (default: 'deny')
hooks: { ... }, // Optional lifecycle hooks
})What is the difference between engine.can() and engine.check()?
They are the same. can() is an alias for check(). Both run the full evaluation pipeline
and return a Decision object. Use whichever reads better in your code:
// These are identical:
await engine.can('user-1', 'read', { type: 'post', attributes: {} })
await engine.check('user-1', 'read', { type: 'post', attributes: {} })What does the Decision object contain?
interface Decision {
allowed: boolean // The final allow/deny result
effect: 'allow' | 'deny' // Same as allowed, as a string
duration: number // Evaluation time in milliseconds
reason?: string // Human-readable reason for the decision
decidingPolicyId?: string // Which policy determined the result
decidingRuleId?: string // Which rule determined the result
}Check result.allowed for the boolean. Use result.reason and result.decidingPolicyId
for debugging or audit logging.
What is the evaluation order?
The engine follows these steps in order:
- Load subject -- get roles and attributes from the adapter (cached)
- Resolve scoped roles -- if a scope is passed, merge scoped roles with base roles
- Build RBAC policy -- convert resolved roles to a synthetic
__rbac__policy (cached) - Load ABAC policies -- get custom policies from the adapter (cached)
- Run beforeEvaluate hook -- allows request enrichment
- Evaluate all policies -- iterate through RBAC + ABAC policies, evaluate rules
- AND-combine results -- all policies must allow, any deny means overall deny
- Run afterEvaluate hook -- for audit logging
- Run onDeny hook -- if the result was denied
How do I check multiple permissions at once?
Use engine.permissions() for batch checks:
const checks = [
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post', resourceId: 'post-1' },
{ action: 'delete', resource: 'post', resourceId: 'post-1' },
{ action: 'manage', resource: 'dashboard' },
]
const perms = await engine.permissions('user-1', checks)
// { 'create:post': true, 'update:post:post-1': false, ... }This is more efficient than calling can() multiple times because it loads the subject,
roles, and policies once and evaluates all checks against the same cached data.
How are permission map keys formatted?
The keys in the permission map returned by engine.permissions() are formatted as
action:resource or action:resource:resourceId when a resource ID is provided:
create:post-- action + resourceupdate:post:post-1-- action + resource + resourceIdmanage:dashboard-- action + resource
These keys are generated by the buildPermissionKey() utility function. The same format
is used by client libraries to look up permissions.
What is the environment parameter for?
The environment is an optional object you can pass to engine.can() for context that is neither
subject nor resource. Common examples:
environment.time-- current timestamp for time-based restrictionsenvironment.ip-- client IP for network-based rulesenvironment.dayOfWeek-- day of week for schedule-based access
await engine.can('user-1', 'deploy', resource, {
time: Date.now(),
dayOfWeek: new Date().toLocaleDateString('en', { weekday: 'long' }),
ip: '192.168.1.1',
})Conditions can reference environment values with paths like environment.time or
$environment.dayOfWeek.
What happens when an error occurs during evaluation?
The engine fails closed. If any error occurs during evaluation (adapter failure, condition
evaluation error, etc.), the result defaults to deny. The onError hook is called with the
error for logging. This is a security-first design -- authorization errors should never
accidentally grant access.
Hooks and Extensibility
What hooks does the engine support?
Four lifecycle hooks:
- beforeEvaluate -- runs before evaluation. Can modify the AccessRequest (e.g., enrich with extra attributes from a database). Return the modified request.
- afterEvaluate -- runs after evaluation with the request and decision. Use for audit logging, metrics, or analytics.
- onDeny -- runs only when the decision is deny. Use for alerting, rate limiting, or logging denied access attempts.
- onError -- runs when an error occurs during evaluation. Use for error tracking and monitoring.
How do I enrich requests with beforeEvaluate?
const engine = new Engine({
adapter,
hooks: {
beforeEvaluate: async (request) => {
// Fetch the resource from the database to get ownerId
const post = await db.posts.findUnique({ where: { id: request.resource.id } })
return {
...request,
resource: {
...request.resource,
attributes: { ...request.resource.attributes, ownerId: post?.authorId },
},
}
},
},
})This is useful when the caller does not have all the resource attributes at the time of the permission check. The hook can fetch them from a database or API.
Are hooks called during engine.explain()?
Only beforeEvaluate is called during explain(). The afterEvaluate, onDeny, and onError
hooks are NOT called. This is by design -- explain() is a read-only diagnostic tool and should
not generate audit logs, alerts, or side effects. This means you can call explain() freely
in debug tooling without triggering production hooks.
What happens if a hook throws an error?
If beforeEvaluate throws, the evaluation is aborted and the result defaults to deny (fail
closed). The onError hook is called with the error. If afterEvaluate or onDeny throws,
the error is caught by onError but does not change the already-computed decision. If onError
itself throws, the error is silently swallowed to prevent cascading failures.
Caching and Performance
How does caching work?
The engine maintains four LRU caches:
- Policy cache -- caches the list of policies from the adapter
- Role cache -- caches the list of role definitions from the adapter
- RBAC policy cache -- caches the synthetic
__rbac__policy built from roles - Subject cache -- caches per-subject data (roles, attributes, scoped roles)
All caches have a configurable TTL (default: 60 seconds). The subject cache has a configurable max size (default: 1000 entries). Caches are automatically populated on first access and refreshed when the TTL expires.
How do I change the cache TTL?
Pass cacheTTL (in seconds) to the Engine constructor:
const engine = new Engine({ adapter, cacheTTL: 120 }) // 2 minutes
const engine = new Engine({ adapter, cacheTTL: 0 }) // disable cachingSetting cacheTTL: 0 disables caching entirely -- every permission check will hit the adapter.
This is useful for testing but not recommended for production.
How do I manually invalidate caches?
The engine provides several invalidation methods:
engine.invalidate()-- clears all four cachesengine.invalidateSubject('user-1')-- clears one subject's cached dataengine.invalidatePolicies()-- clears the policy cacheengine.invalidateRoles()-- clears the role and RBAC policy caches
The admin API automatically invalidates relevant caches when you save/delete policies or
assign/remove roles through engine.admin.* methods.
What is the performance like?
With cached data, a typical evaluation takes under 1 millisecond. The engine loads policies,
roles, and subjects once and caches them. Each check only runs the evaluation logic -- iterating
over policies, matching rules, and evaluating conditions. For batch checks, use
engine.permissions() which loads data once and evaluates many checks against it. The Decision
object includes a duration field (in milliseconds) so you can monitor evaluation times in
production.
Why is engine.permissions() faster than multiple engine.can() calls?
engine.permissions() loads the subject, roles, and policies once, then evaluates all permission
checks against the same loaded data. If you called engine.can() 10 times, even with caching
you would have 10 separate async calls with cache lookups. permissions() does it in one call
with one set of cache lookups. For UI permission maps with 5-20 checks, this makes a measurable
difference.
What happens when the subject cache is full?
The subject cache uses an LRU (Least Recently Used) eviction policy. When the cache reaches
maxSubjectCacheSize (default: 1000), the least recently accessed entry is evicted to make room
for the new entry. This means frequently active users stay cached while inactive users are evicted.
If you have more than 1000 concurrent active users, increase maxSubjectCacheSize accordingly.
Admin API
What is the Admin API?
The Admin API is accessed via engine.admin and provides methods for managing authorization
data at runtime. It wraps the adapter methods and automatically invalidates relevant caches
when data changes. This is how you manage roles, policies, and subject assignments in a running
application.
What operations does the Admin API support?
- Policies:
savePolicy(),deletePolicy(),listPolicies(),getPolicy() - Roles:
saveRole(),deleteRole(),listRoles(),getRole() - Subject roles:
assignRole(),removeRole(),getSubjectRoles() - Subject attributes:
setSubjectAttributes(),getSubjectAttributes() - Scoped roles:
assignScopedRole(),removeScopedRole(),getSubjectScopedRoles()
Each mutation method automatically invalidates the appropriate cache (e.g., savePolicy()
invalidates the policy cache, assignRole() invalidates the specific subject's cache).
Do Admin API operations invalidate the cache automatically?
Yes. Each admin operation invalidates the relevant cache:
savePolicy()/deletePolicy()-- invalidates policy cachesaveRole()/deleteRole()-- invalidates role cache and RBAC policy cacheassignRole()/removeRole()-- invalidates the specific subject's cachesetSubjectAttributes()-- invalidates the specific subject's cache
This means changes take effect immediately for the next permission check without waiting for the cache TTL to expire.
Adapters and Storage
What is an adapter?
An adapter is the data layer interface that the engine uses to load and store authorization data (policies, roles, subject assignments). duck-iam provides four built-in adapters:
- MemoryAdapter -- stores everything in memory. Great for testing and prototyping.
- PrismaAdapter -- stores in a database via Prisma ORM.
- DrizzleAdapter -- stores in a database via Drizzle ORM.
- HttpAdapter -- fetches from a remote authorization service via HTTP.
Can I use MemoryAdapter in production?
You can, but be aware of the trade-offs. MemoryAdapter stores all data in process memory, so it is lost when the process restarts. It does not share state across multiple server instances. It is best for: testing, prototyping, single-instance applications with static role/policy definitions, or edge/serverless functions where you define everything in code.
How do I store policies in a database?
Use a database adapter:
import { PrismaAdapter } from '@gentleduck/iam/adapters/prisma'
const adapter = new PrismaAdapter({ prisma: prismaClient })
// Or with Drizzle:
import { DrizzleAdapter } from '@gentleduck/iam/adapters/drizzle'
const adapter = new DrizzleAdapter({ db: drizzleInstance })These adapters store policies, roles, and subject data in your database tables. When loading
policies from external sources, always validate them with validatePolicy() before passing
them to the engine.
When should I use the HttpAdapter?
Use HttpAdapter when your authorization data lives in a separate service -- for example, a centralized authorization microservice that multiple applications share. The HttpAdapter fetches policies, roles, and subject data via HTTP requests. Configure the base URL and optional headers (for authentication):
import { HttpAdapter } from '@gentleduck/iam/adapters/http'
const adapter = new HttpAdapter({
baseURL: 'https://auth-service.internal/api',
headers: { 'Authorization': 'Bearer <service-token>' },
})How do I write a custom adapter?
Implement the Adapter interface, which combines PolicyStore, RoleStore, and SubjectStore:
interface Adapter extends PolicyStore, RoleStore, SubjectStore {}
// PolicyStore: listPolicies, getPolicy, savePolicy, deletePolicy
// RoleStore: listRoles, getRole, saveRole, deleteRole
// SubjectStore: getSubjectRoles, setSubjectRoles, getSubjectAttributes,
// setSubjectAttributes, getSubjectScopedRoles (optional)getSubjectScopedRoles() is optional -- if your adapter does not support multi-tenant scoping,
omit it. Use the built-in MemoryAdapter source code as a reference implementation.
Do database adapters add dependencies?
No. Database adapters use peer dependencies. If you use PrismaAdapter, you must already have
@prisma/client installed in your project. If you use DrizzleAdapter, you must already have
drizzle-orm installed. The duck-iam package itself does not bundle these ORMs.
Server Integrations
How do I use duck-iam with Express?
Use the Express middleware:
import { createAccessMiddleware } from '@gentleduck/iam/server/express'
const accessMiddleware = createAccessMiddleware({
engine,
extractUserId: (req) => req.user.id,
extractAction: (req) => req.method === 'GET' ? 'read' : 'update',
extractResource: (req) => ({ type: 'post', attributes: {} }),
})
app.use('/api/posts', accessMiddleware)The middleware extracts the subject, action, and resource from the request, runs
engine.can(), and either calls next() or responds with 403.
How do I use duck-iam with NestJS?
Use the NestJS guard and decorator:
import { AccessGuard, RequireAccess } from '@gentleduck/iam/server/nest'
// Register the guard globally or per-controller
@UseGuards(AccessGuard)
@Controller('posts')
export class PostsController {
@RequireAccess('update', 'post')
@Put(':id')
updatePost() { ... }
}The AccessGuard extracts the user from the request (via your auth guard) and checks
the permission specified in @RequireAccess(). The guard returns 403 if denied.
How do I use duck-iam with Next.js?
Use the Next.js route wrappers:
import { withAccess } from '@gentleduck/iam/server/next'
export const GET = withAccess({
engine,
action: 'read',
resource: 'post',
extractUserId: (req) => getUserFromSession(req),
})(async (req) => {
return NextResponse.json({ posts: await getPosts() })
})Works with both App Router (Route Handlers) and Pages Router (API Routes).
How do I use duck-iam with Hono?
Use the Hono middleware:
import { createAccessMiddleware } from '@gentleduck/iam/server/hono'
const accessMiddleware = createAccessMiddleware({
engine,
extractUserId: (c) => c.get('userId'),
extractAction: (c) => c.req.method === 'GET' ? 'read' : 'update',
extractResource: (c) => ({ type: 'post', attributes: {} }),
})
app.use('/api/posts/*', accessMiddleware)What if my framework is not listed?
Use the generic server helper from @gentleduck/iam/server/generic. It provides framework-agnostic
functions that you can wrap in your framework's middleware pattern. Or call engine.can()
directly in your request handler -- the engine does not depend on any framework.
How do I create a permissions endpoint for the frontend?
Create an endpoint that calls engine.permissions() and returns the result:
app.get('/api/permissions', async (req, res) => {
const userId = req.user.id
const checks = [
{ action: 'create', resource: 'post' },
{ action: 'update', resource: 'post' },
{ action: 'delete', resource: 'post' },
{ action: 'manage', resource: 'dashboard' },
]
const perms = await engine.permissions(userId, checks)
res.json(perms)
})The frontend receives a flat Record<string, boolean> like
{"{"} "create:post": true, "delete:post": false {"}"}
and uses it for rendering decisions.
Client Libraries
How do client libraries work?
Client libraries fetch the user's permission map (a Record<string, boolean>) from a server
endpoint and expose it to your components. Permissions are always evaluated on the server -- the
client only receives the boolean results for rendering decisions (show/hide buttons, enable/disable
actions). This means the client never sees your policies, roles, or conditions.
How do I use duck-iam with React?
import { AccessProvider, useAccess } from '@gentleduck/iam/client/react'
// Wrap your app:
<AccessProvider fetchPermissions={() => fetch('/api/permissions').then(r => r.json())}>
<App />
</AccessProvider>
// In any component:
function PostActions() {
const { can, isLoading } = useAccess()
if (isLoading) return <Spinner />
return (
<>
{can('create:post') && <CreateButton />}
{can('delete:post') && <DeleteButton />}
</>
)
}How do I use duck-iam with Vue?
import { createAccessPlugin, useAccess } from '@gentleduck/iam/client/vue'
// Install plugin:
app.use(createAccessPlugin({
fetchPermissions: () => fetch('/api/permissions').then(r => r.json()),
}))
// In any component:
const { can, isLoading } = useAccess()
// Use v-if="can('create:post')" in templateHow do I use duck-iam without React or Vue?
Use the vanilla client:
import { createAccessClient } from '@gentleduck/iam/client/vanilla'
const access = createAccessClient({
fetchPermissions: () => fetch('/api/permissions').then(r => r.json()),
})
await access.load()
access.can('create:post') // true or falseWorks with any framework or no framework. Also works with Svelte, Solid, Angular, or plain JavaScript.
Is it safe to send permissions to the frontend?
Yes, because the permission map is just a flat map of booleans ("create:post": true). It does
not expose your policy definitions, conditions, role structures, or any business logic. The
client only knows "can this user do this action" -- not why. Always enforce permissions on the
server too. Client-side checks are for UI rendering only and should never be your only
authorization check.
How do I refresh permissions after a role change?
Call the refresh() method on the client:
// React:
const { refresh } = useAccess()
await refresh() // re-fetches from the server
// Vanilla:
await access.refresh()You should call this after events that change the user's roles or permissions (e.g., after an admin promotes a user, or after the user switches organizations).
Debugging and Troubleshooting
How do I debug permission denials?
Use engine.explain():
const result = await engine.explain('user-1', 'update', {
type: 'post',
id: 'post-5',
attributes: { ownerId: 'user-3' },
})
console.log(result.summary)The summary tells you: the final result, the subject's roles, which policies were evaluated, which rules matched or were skipped, and which conditions failed. For each condition, the trace shows the expected value, the actual value, and whether it passed.
What is the difference between explain() and check()?
check() (and can()) run the evaluation and return a Decision (boolean + metadata).
explain() runs the same evaluation but does NOT short-circuit -- it evaluates ALL policies
and ALL rules even after a deny, so you get the complete picture. It returns an ExplainResult
with full traces of every policy, rule, and condition. explain() also does not trigger
afterEvaluate, onDeny, or onError hooks.
The summary says "No matching rules" -- what does that mean?
It means no rules in any policy matched the request's action and resource combination. This usually means the subject does not have a role that grants that permission. Check:
result.subject.roles-- does the subject have the expected roles?- Is the action spelled correctly? ("read" vs "Read")
- Is the resource type spelled correctly? ("post" vs "posts")
- Did you pass the scope parameter if the roles are scoped?
A condition is failing but I think it should pass. How do I debug it?
Look at the condition trace in the ExplainResult. Each ConditionLeafTrace shows:
- field -- the path being checked (e.g.,
resource.attributes.ownerId) - expected -- the value from the condition definition
- actual -- the value resolved from the request at runtime
- result -- true or false
Common problems: the field path is wrong (author vs ownerId), the actual value is null
because the attribute was not passed, or a $ variable was not resolved because the request
context is missing that field.
My scoped roles are not being applied. What is wrong?
Check with explain():
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, either: your adapter does not implement
getSubjectScopedRoles(), there are no scoped role assignments for this subject/scope
combination, or you forgot to pass the scope parameter.
Why was my request allowed when it should be denied?
Use explain() and look at which policies allowed it:
for (const pt of result.policies) {
if (pt.result === 'allow') {
console.log('Policy "' + pt.policyId + '" allowed:')
for (const rule of pt.rules.filter(r => r.matched && r.effect === 'allow')) {
console.log(' Rule "' + rule.ruleId + '" matched')
}
}
}Common causes: a wildcard permission (grant('*', '*')) on a role, a policy with
allow-overrides that is too permissive, or the deny policy's target filter does not
match the request so it is skipped.
Common mistakes caught by validatePolicy()?
- Capitalized effect values (
"Allow"instead of"allow") - Wrong operator names (
"equal"instead of"eq","not_equal"instead of"neq") - Missing required fields (
id,name,algorithm,rules) - Missing rule fields (
id,effect,priority,actions,resources) - Conditions using a bare object instead of an
all/any/nonegroup - Invalid combining algorithm names
- Duplicate rule IDs within a policy (warning)
Security and Edge Cases
What does "fail closed" mean?
Fail closed means that when something goes wrong, the system defaults to deny. In duck-iam:
- If no rules match, the result is deny (via
defaultEffect: 'deny') - If an adapter call throws an error, the result is deny
- If a condition evaluation fails, that condition returns false
- If a
beforeEvaluatehook throws, the result is deny - If the subject is not found, the result is deny
This is the opposite of "fail open" (defaulting to allow), which would be a security risk. An authorization system should never accidentally grant access.
Is duck-iam protected against prototype pollution?
Yes. The field resolution function blocks access to __proto__, constructor, and prototype
in dot-notation paths. A condition with a field like resource.attributes.__proto__.polluted
will not resolve and the condition will fail safely. This prevents attackers from crafting
condition paths that exploit prototype chain traversal.
What is defense-in-depth in the context of duck-iam?
duck-iam's cross-policy AND-combination provides defense-in-depth. Every policy is an independent security gate. The RBAC policy (from roles) must allow, AND every custom ABAC policy must allow. A deny from any single policy means overall deny. This means you can add new restriction policies without worrying about existing policies overriding them. Each layer adds protection, and no single layer can bypass the others.
Can I rely on client-side permission checks for security?
No. Client-side checks are for UI rendering only (show/hide buttons, enable/disable actions).
A user can always bypass client-side checks by modifying the JavaScript, calling the API directly,
or tampering with the permission map. Always enforce permissions on the server using middleware
or engine.can() in your request handlers. The client library is a convenience for better UX,
not a security boundary.
What happens when a subject has no roles assigned?
The subject has zero permissions from RBAC. The synthetic __rbac__ policy will have no matching
rules, so it returns the default effect (deny). If no custom ABAC policies explicitly allow
anything for roleless subjects, the result is deny for everything. This is the expected
secure default -- no roles means no access.
What happens when the subject does not exist in the adapter?
If the adapter returns no data for a subject (no roles, no attributes), the subject is treated as having no roles and no attributes. This means zero RBAC permissions and no subject attributes for conditions to match against. The result will be deny unless an ABAC policy explicitly allows access without any subject-based conditions.
What happens if a policy has no rules?
An empty policy (no rules) returns the default effect for the combining algorithm. For
deny-overrides and highest-priority, no matching rules means the default effect (deny).
For allow-overrides, no matching allow rules means deny. For first-match, no matching
rules means the default effect. In practice, an empty policy always results in deny with
the default configuration.
Can deeply nested conditions cause stack overflows?
No. The engine enforces a maximum nesting depth of 10 levels for condition groups. Beyond this depth, the condition evaluation stops and returns false (fail closed). This prevents both accidental complexity and deliberate denial-of-service via deeply nested condition trees.
Can the matches operator cause ReDoS attacks?
The matches operator creates a RegExp from the condition value. If untrusted users can
define policy conditions (e.g., via an admin dashboard), they could craft a malicious regex
pattern that causes catastrophic backtracking. To mitigate this: validate regex patterns
before saving policies, limit who can create conditions with matches, or use simpler
operators like starts_with, ends_with, or eq when possible.
Type Safety
What is the difference between typed and untyped mode?
Untyped (direct imports): Import defineRole, policy, Engine directly from
@gentleduck/iam. Actions and resources accept any string. Quick and flexible, but typos are not caught
at compile time.
Typed (createAccessConfig): Call createAccessConfig() with as const arrays.
Every builder method is constrained to your declared schema. Misspell an action and TypeScript
catches it before your code runs.
For production applications, the typed approach is strongly recommended. It prevents an entire class of bugs where a permission check silently fails because of a typo.
Why is as const required for createAccessConfig()?
Without as const, TypeScript widens arrays to string[]:
// Without as const:
const actions = ['create', 'read'] // type: string[]
// With as const:
const actions = ['create', 'read'] as const // type: readonly ['create', 'read']createAccessConfig() needs the literal types ('create' | 'read') to constrain builder
methods. With string[], it cannot tell the difference between 'create' and 'craete',
so there is no compile-time error checking.
What does access.checks() do?
access.checks() is a pure typing utility -- it returns the input array unchanged but constrains
the types at compile time. Use it with engine.permissions() for batch permission checks:
const uiChecks = access.checks([
{ action: 'create', resource: 'post' },
{ action: 'manage', resource: 'dashboard' },
// { action: 'approve', resource: 'post' } // ERROR: 'approve' is not valid
])
const perms = await engine.permissions('user-1', uiChecks)At runtime, access.checks() is a no-op. Its only purpose is compile-time validation.
Is the engine typed when created via access.createEngine()?
Yes. When you use access.createEngine() instead of new Engine(), the engine's can(),
check(), explain(), and permissions() methods are all constrained to your schema's
actions and resources. Passing an invalid action or resource is a compile error.
Can I omit scopes from createAccessConfig()?
Yes. Scopes are optional in the config:
const access = createAccessConfig({
actions: ['create', 'read', 'update', 'delete'] as const,
resources: ['post', 'comment'] as const,
// scopes omitted -- scope parameters accept 'string' without constraint
})If you omit scopes, scope parameters in the engine will accept any string. You lose type-checking for scopes but everything else is still constrained.
What is the difference between createAccessConfig() builders and standalone defineRole() / defineRule()?
The difference is compile-time type safety. Both produce identical output at runtime.
Standalone builders default their generics to string, so they accept anything:
import { defineRole } from '@gentleduck/iam'
const viewer = defineRole('viewer')
.grant('read', 'post')
.grant('deletee', 'postt') // no error -- both are valid strings
.build()TypeScript sees 'deletee' as a perfectly valid string and lets it through. The role
ends up with a permission that never matches any request -- a silent bug you may not
discover until production.
Config-bound builders lock generics to your declared schema:
import { createAccessConfig } from '@gentleduck/iam'
const access = createAccessConfig({
actions: ['create', 'read', 'update', 'delete'] as const,
resources: ['post', 'user'] as const,
})
const viewer = access
.defineRole('viewer')
.grant('read', 'post') // OK
.grant('deletee', 'postt') // compile error on both arguments
.build()Because as const produces literal unions, TypeScript knows the only valid actions are
'create' | 'read' | 'update' | 'delete' | '*' and the only valid resources are
'post' | 'user' | '*'. The typos are caught before the code runs.
This applies to every builder on the config object:
access.defineRole()-- constrained.grant()and.inherits()access.defineRule()-- constrained.on()and.of()access.policy()-- constrained rule actions and resourcesaccess.when()-- constrained condition builderaccess.checks()-- constrained permission check definitionsaccess.createEngine()-- engine typed with your schema, soengine.can()is also constrained
The engine does not care which approach was used. Both produce the same Role and
Policy objects at runtime. The only difference is whether
TypeScript can help you catch mistakes before they ship.
How do I share my action and resource types across the codebase?
Extract type aliases from your config and export them:
// shared/access.ts
export const access = createAccessConfig({
actions: ['create', 'read', 'update', 'delete'] as const,
resources: ['post', 'user'] as const,
})
export type AppAction = (typeof access.actions)[number]
// => 'create' | 'read' | 'update' | 'delete'
export type AppResource = (typeof access.resources)[number]
// => 'post' | 'user'Then use these types everywhere -- in your NestJS guard, your @Authorize decorator,
your React components, and your API types:
// api/access/guard.ts
import type { Engine } from '@gentleduck/iam'
import type { AppAction, AppResource } from '@shared/access'
constructor(@Inject(ACCESS_ENGINE_TOKEN) engine: Engine<AppAction, AppResource>) { ... }
// api/access/authorize.ts
import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
import type { AppAction, AppResource } from '@shared/access'
export const Authorize = createTypedAuthorize<AppAction, AppResource>()This creates a single source of truth. Add a new action or resource to the config, and TypeScript tells you everywhere it needs to be handled.
Can I mix config-bound and standalone builders?
Technically yes -- both produce the same Role and
Policy objects at runtime. The engine processes them identically.
However, there is no reason to mix them. Standalone builders bypass all the type safety that
createAccessConfig() provides. A single
defineRole() call with a typo defeats the purpose
of constraining everything else. Pick one approach and use it consistently.
Common Patterns
How do I implement owner-only access?
Create a policy that denies non-owners:
const ownerPolicy = policy('owner-only')
.algorithm('deny-overrides')
.rule('deny-non-owner', r => r
.deny()
.on('update', 'delete')
.of('post')
.priority(100)
.when(w => w
.check('resource.attributes.ownerId', 'neq', '$subject.id')
.not(w => w.role('admin')) // admins are exempt
)
)
.build()The key: $subject.id resolves to the actual user's ID at runtime. Combined with neq
and deny, this blocks anyone who is not the owner. The .not() makes admins exempt.
Make sure you pass ownerId in resource attributes.
How do I implement time-based access restrictions?
Pass time information in the environment and check it in conditions:
// When checking:
await engine.can('user-1', 'deploy', resource, {
dayOfWeek: new Date().toLocaleDateString('en', { weekday: 'long' }),
hour: new Date().getHours(),
})
// In a policy rule:
.when(w => w
.check('environment.dayOfWeek', 'nin', ['Saturday', 'Sunday'])
.check('environment.hour', 'gte', 9)
.check('environment.hour', 'lt', 17)
)This restricts deployments to weekdays between 9 AM and 5 PM.
How do I let admins bypass all restrictions?
Option 1: Give the admin role wildcard permissions:
const admin = defineRole('admin').grant('*', '*').build()Option 2: Exclude admins from deny conditions using .not():
.when(w => w
.check('resource.attributes.ownerId', 'neq', '$subject.id')
.not(w => w.role('admin')) // admin is exempt from this deny
)Option 1 gives full RBAC access. Option 2 exempts admins from specific ABAC restrictions. For maximum flexibility, combine both.
How do I restrict actions based on resource status?
Pass the status as a resource attribute and check it in conditions:
// When checking:
await engine.can('user-1', 'update', {
type: 'post',
id: 'post-1',
attributes: { status: 'locked', ownerId: 'user-1' },
})
// In a policy:
.rule('deny-locked-edit', r => r
.deny()
.on('update', 'delete')
.of('post')
.when(w => w.check('resource.attributes.status', 'eq', 'locked'))
)This denies editing or deleting locked posts regardless of who the user is.
How do I restrict access by IP address?
Pass the IP in the environment and check it in conditions:
await engine.can('user-1', 'manage', resource, {
ip: req.ip,
})
// Policy rule:
.rule('office-only', r => r
.deny()
.on('manage')
.of('settings')
.when(w => w.check('environment.ip', 'not_contains', '10.0.'))
)For more sophisticated IP matching, use the matches operator with a regex pattern.
Can I use duck-iam for feature flags?
Yes, though it is not the primary use case. You can model features as resources and access as actions:
const betaTester = defineRole('beta-tester')
.grant('access', 'feature-dark-mode')
.grant('access', 'feature-ai-assistant')
.build()
await engine.can('user-1', 'access', { type: 'feature-dark-mode', attributes: {} })This works for role-based feature flags. For percentage-based rollouts or A/B testing, use a dedicated feature flag service instead.
Migration and Advanced
Can policies be created and modified at runtime?
Yes. Use the Admin API to create, update, and delete policies at runtime:
await engine.admin.savePolicy(newPolicy)
await engine.admin.deletePolicy('old-policy-id')The admin API automatically invalidates the policy cache when you save or delete. Always
validate dynamic policies with validatePolicy() before saving them, especially if the
policy data comes from user input or an external API.
How do I migrate from a pure RBAC system?
Start by mapping your existing roles to duck-iam roles:
- Define each role with its permissions using
defineRole() - Set up role assignments in your adapter
- Replace your existing
if (user.role === 'admin')checks withengine.can() - Validate roles at startup with
validateRoles() - Add ABAC policies later as needed for fine-grained rules
You can migrate incrementally -- start with RBAC-only and add policies for specific use cases over time. The engine handles both seamlessly.
Can I have multiple engine instances?
Yes. Each engine instance is independent with its own adapter and cache. This is useful for:
- Different authorization domains in the same app (e.g., one for API access, one for admin panel)
- Testing (create a fresh engine per test with MemoryAdapter)
- Microservices that need different policy sets
Does duck-iam work in edge runtimes (Cloudflare Workers, Vercel Edge)?
Yes. The core engine is pure TypeScript with no Node.js-specific APIs. It works in any
JavaScript runtime that supports ES2020+, including Cloudflare Workers, Vercel Edge Functions,
Deno Deploy, and Bun. For edge runtimes, use MemoryAdapter with policies defined in code,
or HttpAdapter to fetch from a central authorization service.
How do I test authorization logic?
Use MemoryAdapter with test data:
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: { 'test-user': ['editor'] },
policies: [ownerPolicy],
})
const engine = new Engine({ adapter, cacheTTL: 0 })
// Test specific scenarios:
const result = await engine.can('test-user', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'test-user' },
})
expect(result.allowed).toBe(true)Set cacheTTL: 0 in tests so you can modify data between assertions without cache issues.
Each test can create its own engine instance with its own adapter for full isolation.
How do I get better test failure messages?
Use engine.explain() instead of engine.can() in your tests. When an assertion fails,
the result.summary tells you exactly why:
const result = await engine.explain('test-user', 'delete', resource)
if (!result.decision.allowed) {
console.log(result.summary)
// Shows which policy/rule/condition caused the denial
}
expect(result.decision.allowed).toBe(true)How do I add audit logging?
Use the afterEvaluate hook:
const engine = new Engine({
adapter,
hooks: {
afterEvaluate: async (request, decision) => {
await auditLog.write({
subjectId: request.subject.id,
action: request.action,
resource: request.resource.type,
resourceId: request.resource.id,
allowed: decision.allowed,
reason: decision.reason,
timestamp: new Date(),
})
},
},
})I still have issues. Where should I ask for help?
Open an issue or discussion with your environment details and a minimal reproduction:
Include: your duck-iam version, the output of engine.explain() for the failing check,
your role definitions, and the relevant policy. The explain() output is the most helpful
piece of information for diagnosing issues.