Quick start
Install duck-iam, declare actions and resources, define a role, wire the memory adapter, and get a first passing permission check in about five minutes
This page takes you from an empty project to a passing permission check, then layers on ABAC policies, owner-only conditions, multi-tenant scopes, server guards, and a client permission map. Every snippet is checked against the current @gentleduck/iam. If you only have five minutes, stop after step 4 - that is the first passing check.
The path
Steps 1 to 4 are the vertical slice: a subject with a role, a grant, and a decision. Steps 5 to 9 are layers you add when you need them - attribute rules, ownership, tenancy, then moving the decision out to your routes and your UI.
Step 1: install
npm i @gentleduck/iam
The package has one runtime dependency (uuid, pulled in only by the Drizzle schema helpers). Every adapter, server middleware, and client library lives behind a subpath export, so you only bundle what you import.
Step 2: declare your access config
createIam() is the entry point. Pass your actions and resources as as const arrays; the returned config threads those literal unions through every builder.
import { createIam } from '@gentleduck/iam/core'
export const access = createIam({
actions: ['create', 'read', 'update', 'delete', 'manage'] as const,
resources: ['post', 'comment', 'user', 'team', 'billing'] as const,
roles: ['viewer', 'editor', 'moderator', 'admin', 'author'] as const,
scopes: ['org-1', 'org-2'] as const,
})
as const is what makes the config type-safe. Without it TypeScript infers string[] and every builder falls back to accepting any string. With it, access.defineRole('viewer').grant('craete', 'post') is a compile error because 'craete' is not in the actions tuple.
| Option | Type | Default | Meaning |
|---|---|---|---|
actions | readonly string[] | required | Actions your application supports |
resources | readonly string[] | required | Resource types your application manages |
roles | readonly string[] | [] | Role IDs; constrains defineRole and assignRole |
scopes | readonly string[] | [] | Scope strings for multi-tenant authorization |
context | object | DotPath.IDefaultContext | Phantom field for typed dot-paths. Pass {} as unknown as AppContext; the runtime value is never read |
The returned object exposes defineRole, definePolicy, defineRule, when, createEngine, checks, validateRoles, and validatePolicy. Full reference: createIam options.
Step 3: define roles
Roles are collections of (action, resource) grants. inherits closes over parent roles at load time.
import { access } from './access'
export const viewer = access
.defineRole('viewer')
.desc('Read-only access to content')
.grantRead('post', 'comment')
.build()
export const editor = access
.defineRole('editor')
.desc('Can create and edit content')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grant('create', 'comment')
.grant('update', 'comment')
.build()
export const moderator = access
.defineRole('moderator')
.desc('Can delete content')
.inherits('editor')
.grant('delete', 'post')
.grant('delete', 'comment')
.build()
export const admin = access
.defineRole('admin')
.desc('Full access')
.inherits('moderator')
.grantCRUD('user')
.grant('manage', 'team')
.grant('manage', 'billing')
.build()
export const allRoles = [viewer, editor, moderator, admin]
grantRead(...resources) is shorthand for grant('read', r) per resource. grantCRUD(resource) expands to create, read, update, delete. grantAll(resource) grants '*' on that resource.
build() runs the validator and throws if the role is malformed - a dangling inherits, an empty role, an inheritance chain deeper than the runtime's MAX_INHERITANCE_DEPTH of 32. The message is prefixed [@gentleduck/iam:builder] RoleBuilder.build(): role rejected by validator. See role definition and inheritance.
inherits is additive only. To restrict access below what a parent grants, add an ABAC deny policy - see combining algorithms.Step 4: create the engine and run your first check
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { access } from './access'
import { allRoles } from './roles'
const adapter = new IamMemoryAdapter({
roles: allRoles,
assignments: {
'user-alice': ['admin'],
'user-bob': ['editor'],
'user-carol': ['viewer'],
},
})
export const engine = access.createEngine({ adapter })
// Alice is an admin - she can do everything.
await engine.can('user-alice', 'manage', { type: 'billing', attributes: {} })
// -> true
// Bob is an editor - he can create posts but not delete them.
await engine.can('user-bob', 'create', { type: 'post', attributes: {} })
// -> true
await engine.can('user-bob', 'delete', { type: 'post', attributes: {} })
// -> false
// Carol is a viewer - reads only.
await engine.can('user-carol', 'read', { type: 'post', attributes: {} })
// -> true
await engine.can('user-carol', 'create', { type: 'post', attributes: {} })
// -> false
That is the first passing check. can(subjectId, action, resource, environment?, scope?) always returns a plain boolean, in either engine mode, and never rejects - a subject-resolution failure or an adapter timeout is routed to hooks.onError and returns false.
What the engine did
Here is the path that first can() call took.
The subject cache miss is what makes the first call expensive; every later call for user-bob inside the TTL window skips the adapter entirely. listRoles is fetched because inheritance is flattened at load, not per request. The roles are compiled into an ABAC policy by rolesToPolicy(), so an RBAC grant and an attribute policy go through the same evaluator - there is no second code path. See evaluation pipeline and caching.
can vs check vs authorize vs explain
| Method | Input | Returns |
|---|---|---|
can(subjectId, action, resource, environment?, scope?) | subject ID | Promise<boolean> always |
check(subjectId, action, resource, environment?, scope?) | subject ID | Promise<boolean> in production mode, Promise<AccessControl.IDecision> in development |
authorize(request) | a full IamRequest.IAccessRequest you built yourself | mode-dependent, same as check |
explain(subjectId, action, resource, environment?, scope?) | subject ID | Promise<Explain.IResult>; throws in production mode |
permissions(subjectId, checks, environment?, opts?) | subject ID plus up to 1024 checks | a permission map |
Full signatures: engine methods.
Step 5: add an ABAC policy
Roles answer "who". Policies answer "under what conditions". This one blocks writes outside business hours.
import { access } from './access'
export const businessHoursPolicy = access
.definePolicy('business-hours')
.name('Business hours only')
.desc('Deny write operations outside 09:00-18:00 UTC')
.algorithm('deny-overrides')
.rule('deny-after-hours', (r) =>
r
.deny()
.on('create', 'update', 'delete')
.of('post', 'comment')
.whenAny((w) => w.env('hour', 'lt', 9).env('hour', 'gte', 18))
.desc('Block writes outside business hours'),
)
.build()
await engine.admin.savePolicy(businessHoursPolicy)
await engine.can(
'user-bob',
'create',
{ type: 'post', attributes: {} },
{ hour: 22 },
)
// -> false
whenAny is OR: the rule fires when the hour is before 9 or at 18 and later. env('hour', ...) is shorthand for the dot-path environment.hour, read from the environment object, the fourth argument to can(). algorithm('deny-overrides') is the default and means any deny inside this policy wins.
The four intra-policy combining algorithms are deny-overrides (default), allow-overrides, first-match, and highest-priority. Across policies the engine's policyCombine setting applies - 'and' by default, meaning every applicable policy must allow. See combining algorithms and cross-policy combination.
.target({ actions, resources }) skips the policy when the request does not match. If the target admits a pair that none of the policy's allow rules covers, build() throws UNREACHABLE_TARGET - every request matching that target would be denied silently. Add a rule that allows the pair, or narrow the target. See targets.Step 6: owner-only permissions
grantWhen attaches a condition to a single grant. w.isOwner() is shorthand for resource.attributes.ownerId eq $subject.id.
export const author = access
.defineRole('author')
.desc('Can update and delete own posts only')
.inherits('viewer')
.grant('create', 'post')
.grantWhen('update', 'post', (w) => w.isOwner())
.grantWhen('delete', 'post', (w) => w.isOwner())
.build()
Pass the resource's attributes on the check so the condition has something to read:
await engine.admin.saveRole(author)
await engine.admin.assignRole('user-dana', 'author')
await engine.can('user-dana', 'update', {
type: 'post',
id: 'post-123',
attributes: { ownerId: 'user-dana' },
})
// -> true
await engine.can('user-dana', 'update', {
type: 'post',
id: 'post-123',
attributes: { ownerId: 'user-alice' },
})
// -> false
The $ prefix marks a value as a reference into the request rather than a literal. At evaluation time $subject.id resolves against the request that is being evaluated. Pass a different owner field with w.isOwner('resource.attributes.createdBy'). See dollar variables.
eq is ===. A subject ID of '42' never equals an ownerId of 42. gt / gte / lt / lte return false unless both operands are numbers, so an ISO date string compared with gte is always false - use before / after for temporal values.Step 7: multi-tenant scoped roles
Assign the same subject different roles per scope, then pass the scope on the check.
// Alice is admin in org-1 but only a viewer in org-2.
await engine.admin.assignRole('user-alice', 'admin', 'org-1')
await engine.admin.assignRole('user-alice', 'viewer', 'org-2')
await engine.can('user-alice', 'manage', { type: 'team', attributes: {} }, undefined, 'org-1')
// -> true
await engine.can('user-alice', 'manage', { type: 'team', attributes: {} }, undefined, 'org-2')
// -> false
The engine merges the subject's global roles with the scoped assignments whose scope matches the request. Matching is exact by default (scopeMode: 'flat'). Set scopeMode: 'hierarchical' on the engine config to treat a dot-delimited scope as a path, so a grant on 'org-1' applies to 'org-1.team-2.repo-3'. See scoped roles.
engine.getEffectiveRoles(subjectId, scope?) returns the merged list if you need to see what the engine resolved.
Step 8: guard a route
Every framework wrapper is built on engine.can() and takes the same three leading arguments.
Express
import express from 'express'
import { iamAccessMiddleware, iamGuard } from '@gentleduck/iam/server/express'
import { engine } from './lib/engine'
const app = express()
// Option A: global middleware, infers action from the HTTP method and
// resource from the first path segment.
app.use(iamAccessMiddleware(engine, { getUserId: (req) => req.user?.id ?? null }))
// Option B: an explicit per-route guard.
app.get('/posts', iamGuard(engine, 'read', 'post'), (req, res) => res.json({ posts: [] }))
app.delete('/posts/:id', iamGuard(engine, 'delete', 'post'), (req, res) => res.json({ deleted: true }))
app.listen(3000)
iamGuard reads the resource ID from req.params.id. Both helpers reply 401 when getUserId returns null and 403 when the engine denies.
Hono
import { Hono } from 'hono'
import { iamAccessMiddleware, iamGuard } from '@gentleduck/iam/server/hono'
import { engine } from './lib/engine'
const app = new Hono()
app.use('*', iamAccessMiddleware(engine, { getUserId: (c) => (c.get('userId') as string | null) ?? null }))
app.delete('/posts/:id', iamGuard(engine, 'delete', 'post'), (c) => c.json({ deleted: true }))
export default app
NestJS
import { Controller, Delete, Get, Param, UseGuards } from '@nestjs/common'
import { IamAuthorize, iamNestAccessGuard } from '@gentleduck/iam/server/nest'
import { engine } from '../lib/engine'
const canActivate = iamNestAccessGuard(engine, { getUserId: (req) => req.user?.sub ?? null })
@Controller('posts')
@UseGuards({ canActivate } as never)
export class PostsController {
@Get()
@IamAuthorize({ action: 'read', resource: 'post' })
findAll() {
return []
}
@Delete(':id')
@IamAuthorize({ action: 'delete', resource: 'post' })
remove(@Param('id') id: string) {
return { deleted: id }
}
}
iamNestAccessGuard returns a canActivate body; handlers with no IamAuthorize metadata pass through.
Next.js
import { withIamAccess } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { getSession } from '@/lib/auth'
export const DELETE = withIamAccess(
engine,
'delete',
'post',
async (req, ctx) => {
const params = ctx.params instanceof Promise ? await ctx.params : ctx.params
return Response.json({ deleted: params?.id })
},
{
getUserId: async () => {
const session = await getSession()
return session?.userId ?? null
},
},
)
withIamAccess throws at construction when getUserId is omitted, and the Hono helpers read only c.get('userId') - there is no x-user-id header fallback on any framework. A raw request header is spoofable with one curl. Derive identity from a cookie session, a JWT your middleware verified, or mTLS. See production hardening.Per-framework detail: express, hono, nest, next.
Step 9: send a permission map to the client
engine.permissions() evaluates a batch of checks in one pass and returns a map keyed [scope:]action:resource[:resourceId]. Serialize it into your page and the client checks become synchronous object lookups.
import { getIamPermissions } from '@gentleduck/iam/server/next'
import { engine } from '@/lib/engine'
import { getSession } from '@/lib/auth'
import { AccessProvider } from '@/lib/access-client'
export default async function Layout({ children }: { children: React.ReactNode }) {
const session = await getSession()
const permissions = await getIamPermissions(engine, session.userId, [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post' },
{ action: 'manage', resource: 'team' },
])
return <AccessProvider permissions={permissions}>{children}</AccessProvider>
}
'use client'
import React from 'react'
import { createIamAccessControl } from '@gentleduck/iam/client/react'
export const { AccessProvider, useAccess, usePermissions, Can, Cannot } = createIamAccessControl(React)
'use client'
import { Can, Cannot, useAccess } from '@/lib/access-client'
export function PostActions() {
const { can } = useAccess()
return (
<div>
{can('update', 'post') && <button type="button">Edit post</button>}
<Can action="delete" resource="post" fallback={null}>
<button type="button">Delete post</button>
</Can>
<Cannot action="manage" resource="team">
<p>You do not have permission to manage this team.</p>
</Cannot>
</div>
)
}
createIamAccessControl(React) takes the host React module as an argument so the package never bundles its own copy. permissions() refuses batches over 1024 checks - that is a caller bug, and it throws rather than failing closed. See permission map and React client.
Debugging with explain
When a decision surprises you, engine.explain() returns the whole trace. It is development-mode only and throws explain() is not available in production mode otherwise.
const trace = await engine.explain('user-dana', 'delete', {
type: 'post',
id: 'post-123',
attributes: { ownerId: 'user-alice' },
})
trace.decision // AccessControl.IDecision: { allowed, effect, reason, duration, timestamp, ... }
trace.summary // human-readable one-liner
trace.subject // { id, roles, scopedRolesApplied, attributes }
trace.policies // Explain.IPolicyTrace[]: per policy, per rule, per condition,
// with actual vs expected for every comparison
explain() does not fire afterEvaluate, onDeny, or onError - it is read-only. It does run beforeEvaluate, because that hook changes what is evaluated. Field-by-field reference: explain.
Before you ship
The engine defaults to mode: 'development', which allocates a full IDecision per policy per request. Production deployments should set mode: 'production', call engine.preload() at boot, and engine.dispose() on shutdown. Work through the production hardening checklist before you take traffic.
See also
- Production hardening - the pre-flight checklist, with a link per item
- Cookbook - recipes for the patterns above ownership and tenancy
- Troubleshooting - symptom, cause, fix, keyed to real error messages
- Core concepts - policies, rules, conditions, combining algorithms
- Pairing with duck-auth - where the subject ID comes from