Introduction
Type-safe RBAC + ABAC authorization engine for TypeScript.
What is duck-iam?
duck-iam is an authorization engine that runs Role-Based Access Control (RBAC) and Attribute-Based Access Control (ABAC) through one evaluation pipeline. Define roles with permissions, write policies with conditions, and the engine produces an allow or deny decision.
Actions, resources, scopes, and role IDs are checked at compile time through const assertion generics. A misspelled action or unknown resource fails the type check before the code runs.
Why duck-iam?
Most authorization libraries force a choice between RBAC and ABAC. RBAC is simple but rigid; it cannot express "editors can only update their own posts." ABAC is expressive but expensive; writing every permission as a policy rule is tedious for common role patterns.
duck-iam does both. Use roles for the common cases and policies for the edge cases. Roles are converted into policies internally, so one condition engine handles everything.
What you get:
- Role inheritance:
admininherits fromeditor, which inherits fromviewer. Permissions cascade. - Policies with conditions: "Allow delete on
postwhenresource.attributes.ownerIdequals$subject.id." - Type-safe builders:
defineRole(),definePolicy(),when(), all constrained to your declared actions, resources, and scopes. - Four combining algorithms:
deny-overrides,allow-overrides,first-match,highest-priority. - Multi-tenant scoping: A user can be
adminin org-1 andviewerin org-2. - Caching: LRU caches for policies, roles, and resolved subjects with configurable TTL.
- Dev/Prod mode: Diagnostics and validation in development, fast boolean evaluation in production. See benchmarks.
- Debug tooling:
engine.explain()returns the full evaluation trace: which rules matched, which conditions passed or failed, and why.
Key Features
| Feature | Description |
|---|---|
| Unified RBAC + ABAC | Roles and policies share one evaluation pipeline. RBAC roles convert to a synthetic __rbac__ policy at load time. |
| Type-safe config | defineIam() constrains actions, resources, scopes, and role IDs at the type level. |
| Combining algorithms | Four in-policy algorithms (deny-overrides, allow-overrides, first-match, highest-priority); three cross-policy modes (and, allow-overrides, first-applicable) via IamEngineTypes.IConfig.policyCombine. |
| Multi-tenant scopes | Scoped role assignments: different roles per organization, workspace, or project. |
| Pluggable adapters | Memory, File, Prisma, Drizzle, Redis, HTTP - or write your own by implementing IamAdapter.IAdapter. IamAdapter.IReadOptions.signal supports per-call AbortSignal cancellation. |
| Server + client integrations | Express, Hono, Nest, Next middleware; React + Vue + vanilla clients. Each module ships its own typed namespace (Express.IOptions, ReactClient.IContextValue, ...). |
| Evaluation hooks | beforeEvaluate, afterEvaluate, onDeny, onError, onPolicyError, onMetrics - observe / transform every decision. onMetrics is primitive-only and fires in both modes. |
| Dev/Prod mode | Development returns rich AccessControl.IDecision objects + explain(); production returns plain boolean with zero allocation. See benchmarks and the production guide. |
| Explain / debug | engine.explain() returns Explain.IResult with per-policy, per-rule, per-condition traces. Dev mode only. |
| Validation + limits | validateRoles() and validatePolicy() emit IamValidate.IIssue with closed-set codes. IAM_POLICY_LIMITS caps rule / action / resource counts; MAX_INHERITANCE_DEPTH caps role chains. |
| Operability surface | engine.preload(), engine.healthCheck(), engine.stats.get(), engine.admin.export() / import() snapshots, IConfig.invalidator for cross-instance cache coherence, adapter timeouts, fail-open opt-in. |
Architecture Overview
duck-iam has four core concepts:
Engine: The evaluator. Create an IamEngine with an IamEngineTypes.IConfig, then call engine.can(),
engine.check(), engine.permissions(), or engine.explain(). The engine loads roles and
policies from the adapter, resolves the subject, and runs evaluation.
Adapter: The storage backend. Adapters implement PolicyStore + RoleStore + SubjectStore.
IamMemoryAdapter is for testing and small apps. IamPrismaAdapter and IamDrizzleAdapter are for
production databases. IamHttpAdapter fetches from a remote service.
Policies: ABAC rules organized into policy objects. Each policy has a combining algorithm and a list of rules. Each rule has an effect (allow/deny), target actions and resources, conditions, and a priority. Policies evaluate independently and then combine: a deny from any policy results in an overall deny.
Roles: RBAC definitions with permissions and optional inheritance. Roles are converted to a synthetic ABAC policy, so RBAC and ABAC share the same evaluation path. RBAC permissions can carry conditions (owner-only grants, for example).
How It Works
When an authorization request comes in, the engine follows this pipeline:
- Resolve subject: Load the user's roles, scoped roles, and attributes from the adapter.
- Enrich scoped roles: If the request has a scope (
org-1, for example), merge in roles assigned to that scope. - Load policies: Fetch ABAC policies from the adapter. Convert RBAC roles into a synthetic policy.
- Evaluate: For each policy, check if it applies to the request. For each matching rule, evaluate conditions against the request context. Apply the policy's combining algorithm to produce a per-policy decision.
- Combine: Apply
policyCombineacross policy results. With the default'and', any deny is final. NotApplicable policies (targets miss) are skipped, not folded as default-deny. - Return decision: In development mode the engine returns an
AccessControl.IDecisionwithallowed,effect, the matchingruleandpolicy, areasonstring, and timing. In production it returns a plainboolean.
Quick Example
import { defineRole, IamEngine, IamMemoryAdapter } from "@gentleduck/iam";
// 1. Define roles
const viewer = defineRole("viewer")
.grant("read", "post")
.grant("read", "comment")
.build();
const editor = defineRole("editor")
.inherits("viewer")
.grant("create", "post")
.grant("update", "post")
.build();
const admin = defineRole("admin")
.inherits("editor")
.grant("delete", "post")
.grant("manage", "user")
.build();
// 2. Create adapter and engine
const adapter = new IamMemoryAdapter({
roles: [viewer, editor, admin],
assignments: { "user-1": ["editor"], "user-2": ["viewer"] },
});
const engine = new IamEngine({ adapter });
// 3. Check permissions
await engine.can("user-1", "read", { type: "post", attributes: {} });
// -> true (inherited from viewer)
await engine.can("user-1", "create", { type: "post", attributes: {} });
// -> true (direct editor permission)
await engine.can("user-2", "create", { type: "post", attributes: {} });
// -> false (viewer cannot create)
await engine.can("user-1", "delete", { type: "post", attributes: {} });
// -> false (editor cannot delete, only admin can)
Documentation Map
| Section | What it covers |
|---|---|
| Installation | Install duck-iam, set up an adapter, and run your first permission check. |
| Quick Start | End-to-end guide: define roles, create policies, add middleware, use client hooks. |
| Production deployment | SRE playbook: TTL trade-offs, multi-node invalidation, hooks, retry / circuit breaker, health probe, metrics. |
| Pairing with duck-auth | Wire authentication (duck-auth) and authorization (duck-iam) with a tiny projectToSubject function. |
| Cookbook | Copy-pasteable patterns: owners, public-vs-private, time-bound access, MFA gates, field-level permissions. |
| Troubleshooting | Diagnostic toolbox, common errors, performance gotchas. |
| Core Concepts | Roles, policies, rules, conditions, and combining algorithms. |
| Integrations | Server middleware (Express, Hono, NestJS, Next.js) and client libraries (React, Vue, Vanilla). |
| Advanced | Multi-tenant scoping, evaluation hooks, custom adapters, caching strategies. |
| Benchmarks | Performance numbers against 7 popular libraries, dev vs prod mode overhead. |
| FAQs | Common questions and answers. |
See also
- @gentleduck/auth - pairs with duck-iam for end-to-end identity + authorization. duck-auth proves who the caller is; duck-iam decides what they may do.
Contributing
Open an issue or pull request on the GitHub repository.