Skip to main content
Search...

introduction

duck-iam is a type-safe RBAC + ABAC access control engine for TypeScript with server middleware, client libraries, and pluggable storage adapters.

What is duck-iam?

duck-iam is an authorization engine that unifies 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 through const assertion generics. If you misspell an action or reference a resource that does not exist, TypeScript catches it before your code runs.

Why duck-iam?

Most authorization libraries force you to choose between RBAC and ABAC. RBAC is simple but rigid -- you cannot express "editors can only update their own posts." ABAC is powerful but complex -- defining every permission as a policy rule is tedious for common role patterns.

duck-iam combines both. Define roles for the common cases, add policies for the edge cases, and the engine merges them into a unified evaluation. Roles are internally converted to policies, so the same condition engine handles everything.

What you get:

  • Roles with inheritance -- admin inherits from editor, which inherits from viewer. Define once, permissions cascade.
  • Policies with conditions -- "Allow delete on post when resource.attributes.ownerId equals $subject.id."
  • Type-safe builders -- defineRole(), policy(), when() -- all constrained to your declared actions, resources, and scopes.
  • Four combining algorithms -- deny-overrides, allow-overrides, first-match, highest-priority. Pick the strategy that fits.
  • Multi-tenant scoping -- Assign roles per tenant. A user can be admin in org-1 and viewer in org-2.
  • Built-in caching -- LRU caches for policies, roles, and resolved subjects with configurable TTL.
  • Debug tooling -- engine.explain() returns a full evaluation trace showing exactly which rules matched, which conditions passed or failed, and why.

Key Features

FeatureDescription
Unified RBAC + ABACRoles and policies coexist in one evaluation pipeline.
Type-safe configcreateAccessConfig() locks down actions, resources, and scopes at the type level.
Fluent buildersdefineRole(), defineRule(), policy(), when() -- chainable, readable, type-checked.
Combining algorithmsdeny-overrides, allow-overrides, first-match, highest-priority per policy.
Multi-tenant scopesScoped role assignments -- different roles per organization, workspace, or project.
Server integrationsExpress middleware, Hono middleware, NestJS guard + decorator, Next.js route wrappers.
Client librariesReact provider + hooks, Vue plugin + composables, framework-agnostic vanilla client.
Pluggable adaptersMemory (built-in), Prisma, Drizzle, HTTP -- or write your own by implementing the Adapter interface.
Evaluation hooksbeforeEvaluate, afterEvaluate, onDeny, onError -- intercept and observe every decision.
Explain / debugengine.explain() produces a detailed trace of every policy, rule, and condition evaluated.
ValidationvalidateRoles() and validatePolicy() catch config mistakes like dangling inherits and cycles.

Architecture Overview

duck-iam has four core concepts:

Loading diagram...

Engine -- The central evaluator. You create an Engine with an adapter, 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 the evaluation.

Adapter -- The storage backend. Adapters implement PolicyStore + RoleStore + SubjectStore. The built-in MemoryAdapter works for testing and small apps. Use PrismaAdapter or DrizzleAdapter for production databases, or HttpAdapter to fetch 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 are evaluated independently and then combined -- a deny from any policy results in an overall deny.

Roles -- RBAC definitions with permissions and optional inheritance. Roles are internally converted to a synthetic ABAC policy so that RBAC and ABAC share the same evaluation path. This means RBAC permissions can also carry conditions (e.g., owner-only grants).

How It Works

When an authorization request comes in, the engine follows this pipeline:

Loading diagram...

  1. Resolve subject -- Load the user's roles, scoped roles, and attributes from the adapter.
  2. Enrich scoped roles -- If the request has a scope (e.g., org-1), merge in any roles assigned to that scope.
  3. Load policies -- Fetch all ABAC policies from the adapter. Convert all RBAC roles into a synthetic policy.
  4. Evaluate -- For each policy, check if it applies to the request (target matching). For each matching rule, evaluate conditions against the request context. Apply the policy's combining algorithm to produce a per-policy decision.
  5. Combine -- Across all policies, a deny from any policy means the overall result is deny (defense-in-depth).
  6. Return decision -- The Decision object contains allowed, effect, the matching rule and policy, a human-readable reason, and timing information.

Quick Example

import { defineRole, Engine, MemoryAdapter } 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 MemoryAdapter({
  roles: [viewer, editor, admin],
  assignments: { "user-1": ["editor"], "user-2": ["viewer"] },
});
 
const engine = new Engine({ 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)
import { defineRole, Engine, MemoryAdapter } 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 MemoryAdapter({
  roles: [viewer, editor, admin],
  assignments: { "user-1": ["editor"], "user-2": ["viewer"] },
});
 
const engine = new Engine({ 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

SectionWhat it covers
InstallationInstall duck-iam, set up an adapter, and run your first permission check.
Quick StartEnd-to-end guide -- define roles, create policies, add middleware, use client hooks.
Core ConceptsDeep dive into roles, policies, rules, conditions, and combining algorithms.
IntegrationsServer middleware (Express, Hono, NestJS, Next.js) and client libraries (React, Vue, Vanilla).
AdvancedMulti-tenant scoping, evaluation hooks, custom adapters, caching strategies.
FAQsCommon questions and answers.

Contributing

We welcome contributions of all kinds -- bug reports, feature requests, documentation improvements, and code. Open an issue or pull request on our GitHub repository.

Good ideas get implemented. Bug fixes ship fast. Every contribution is reviewed by humans who care about developer experience.