Imported from gentleeduck/duck-iam (
skills/duck-iam/SKILL.md). Install upstream withnpx skills add gentleeduck/duck-iam --skill duck-iam. Copyright stays with the author.
@gentleduck/iam
Modern ABAC/RBAC access control engine. Framework-agnostic core with integrations for Express, Hono, Next.js, NestJS, React, and Vue.
Package Exports
@gentleduck/iam # Main entry (re-exports core)
@gentleduck/iam/core # Core engine, builders, evaluate, explain
@gentleduck/iam/adapters/memory # In-memory adapter (testing/prototyping)
@gentleduck/iam/adapters/prisma # Prisma ORM adapter
@gentleduck/iam/adapters/drizzle # Drizzle ORM adapter
@gentleduck/iam/adapters/http # HTTP adapter (remote engine)
@gentleduck/iam/server/express # Express middleware + guards
@gentleduck/iam/server/hono # Hono middleware + guards
@gentleduck/iam/server/next # Next.js App Router integration
@gentleduck/iam/server/nest # NestJS decorators + module
@gentleduck/iam/server/generic # Framework-agnostic server helpers
@gentleduck/iam/client/react # React provider, hooks, components
@gentleduck/iam/client/vue # Vue composables
@gentleduck/iam/client/vanilla # Vanilla JS permission checker
Quick Setup
1. Define Your Access Schema
Use createIam with as const arrays for full type safety. Every builder method constrains actions, resources, roles, and scopes at compile time.
import { createIam } from '@gentleduck/iam'
const access = createIam({
actions: ['create', 'read', 'update', 'delete', 'publish'] as const,
resources: ['post', 'comment', 'user'] as const,
roles: ['viewer', 'editor', 'admin'] as const,
scopes: ['org-acme', 'org-globex'] as const,
context: {} as unknown as AppContext, // phantom field for typed dot-paths
})
2. Define Roles (RBAC)
const viewer = access.defineRole('viewer')
.name('Viewer')
.desc('Read-only access')
.grantRead('post', 'comment')
.build()
const editor = access.defineRole('editor')
.name('Editor')
.inherits('viewer')
.grant('create', 'post')
.grant('update', 'post')
.grantCRUD('comment')
.grantWhen('delete', 'post', w => w.isOwner())
.build()
const admin = access.defineRole('admin')
.name('Administrator')
.grantAll('*')
.build()
Role builder API:
.name(n)/.desc(d)-- display name and description.inherits(...roleIds)-- inherit permissions from parent roles (recursive, cycle-safe).scope(s)-- restrict all permissions to a scope.grant(action, resource, scope?)-- single permission.grantScoped(scope, action, resource)-- single scoped permission.grantWhen(action, resource, fn)-- conditional permission.grantAll(resource)-- all actions on a resource ('*'for everything).grantRead(...resources)-- read access on multiple resources.grantCRUD(resource)-- create/read/update/delete.meta(attrs)-- arbitrary metadata.build()-- returns plainRoleobject
3. Define Policies (ABAC)
const weekendDeny = access.definePolicy('deny-weekends')
.name('Deny on Weekends')
.algorithm('deny-overrides')
.rule('r-deny-weekends', r => r
.deny()
.on('create', 'update', 'delete')
.of('*')
.when(w => w.env('dayOfWeek', 'in', [0, 6]))
)
.build()
const ownerPolicy = access.definePolicy('owner-access')
.algorithm('allow-overrides')
.rule('owner-update', r => r
.allow()
.on('update')
.of('post')
.when(w => w.isOwner())
)
.rule('owner-delete', r => r
.allow()
.on('delete')
.of('post')
.when(w => w.isOwner())
)
.build()
Combining algorithms:
deny-overrides-- any deny wins (default, best for restrictions)allow-overrides-- any allow wins (best for RBAC / permissive rules)first-match-- first matching rule wins (firewall-style)highest-priority-- highest priority number wins
Policy builder API:
.name(n)/.desc(d)/.version(v)-- metadata.algorithm(a)-- combining algorithm.target({ actions?, resources?, roles? })-- skip policy if request does not match targets.rule(id, fn)-- inline rule via callback.addRule(rule)-- add pre-built rule.build()-- returns plainPolicyobject
4. Define Rules
const rule = access.defineRule('post.update.owner')
.allow()
.desc('Authors may update their own posts')
.priority(20)
.on('update')
.of('post')
.when(w => w.isOwner())
.build()
Rule builder API:
.allow()/.deny()-- set effect (default: allow).desc(d)-- description.priority(p)-- higher = evaluated first (default: 10).on(...actions)-- actions this rule applies to ('*'for all).of(...resources)-- resources this rule applies to ('*'for all).forScope(...scopes)-- restrict to scopes.when(fn)-- ALL-of conditions (AND).whenAny(fn)-- ANY-of conditions (OR).meta(attrs)-- arbitrary metadata.build()-- returns plainRuleobject
5. Condition Builder (When)
The When builder is used inside .when(), .whenAny(), and .grantWhen() callbacks.
Typed dot-path checks:
.check(field, op, value)-- raw condition with typed field paths.eq(field, value)/.neq(field, value).gt(field, value)/.gte(field, value)/.lt(field, value)/.lte(field, value).in(field, values)/.contains(field, value)/.exists(field)/.matches(field, regex)
Semantic shortcuts:
.attr(path, op, value)-- subject attribute (subject.attributes.{path}).resourceAttr(path, op, value)-- resource attribute (resource.attributes.{path}).env(path, op, value)-- environment attribute (environment.{path}).role(roleId)-- subject has role.roles(...ids)-- subject has one of these roles.scope(id)/.scopes(...ids)-- request is in scope.isOwner(ownerField?)--resource.attributes.ownerId eq $subject.id.resourceType(...types)-- resource type check
Nesting (boolean logic):
.and(fn)-- nested ALL-of group.or(fn)-- nested ANY-of group (at least one must hold).not(fn)-- nested NONE-of group (none may hold)
Example -- complex condition:
.when(w => w
.or(o => o.isOwner().role('admin'))
.env('hour', 'gte', 9)
.env('hour', 'lte', 17)
.not(n => n.attr('status', 'eq', 'banned'))
)
6. Create the Engine
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
const adapter = new IamMemoryAdapter({
policies: [weekendDeny, ownerPolicy],
roles: [viewer, editor, admin],
assignments: { 'user-1': ['editor'], 'user-2': ['viewer'] },
attributes: { 'user-1': { department: 'engineering' } },
})
const engine = access.createEngine({
adapter,
defaultEffect: 'deny', // deny when no rule matches (default)
cacheTTL: 60, // seconds (default: 60)
maxCacheSize: 1000, // LRU cache entries (default: 1000)
hooks: {
beforeEvaluate: async (req) => req,
afterEvaluate: async (req, decision) => { /* audit log */ },
onDeny: async (req, decision) => { /* alert */ },
onError: async (err, req) => { /* report */ },
},
})
Engine API (all async except invalidation):
engine.can(subjectId, action, resource, environment?, scope?)-- returnsboolean.resourceis aResourceobject:{ type: string, id?: string, attributes: Record<string, unknown> }.engine.check(subjectId, action, resource, environment?, scope?)-- returnsDecision(includesallowed,effect,reason,duration,timestamp)engine.authorize(request)-- fullAccessRequestevaluationengine.explain(subjectId, action, resource, environment?, scope?)-- returnsExplainResulttrace (debug only, has overhead)engine.permissions(subjectId, checks, environment?)-- batch check, returns anIamClient.PartialPermissionMapkeyed by"action:resource","action:resource:resourceId","@scope:action:resource"or"@scope:action:resource:resourceId". The@marks the leading segment as a scope, which is what makes a three-segment key unambiguous; build keys withiamBuildPermissionKeyrather than by handengine.admin-- CRUD interface for policies, roles, subjects (lazy-created)engine.invalidate()/engine.invalidateSubject(id)/engine.invalidatePolicies()/engine.invalidateRoles()
7. Server Integrations
Express
import { iamAccessMiddleware, iamAdminRouter, iamGuard } from '@gentleduck/iam/server/express'
// Global middleware
app.use(iamAccessMiddleware(engine, { getUserId: req => req.user?.id }))
// Per-route guard
app.delete('/posts/:id', iamGuard(engine, 'delete', 'post'), handler)
// Admin API
app.use('/api/access-admin', iamAdminRouter(engine)(() => express.Router()))
Hono
import { iamAccessMiddleware, iamGuard } from '@gentleduck/iam/server/hono'
// Identity comes from what your auth middleware set on the context. The
// shipped default reads `c.get('userId')` and never a client-settable header.
app.use('*', iamAccessMiddleware(engine, { getUserId: c => c.get('userId') ?? null }))
app.delete('/posts/:id', iamGuard(engine, 'delete', 'post'), handler)
Next.js App Router
import { checkIamAccess, createIamNextMiddleware, getIamPermissions, withIamAccess } from '@gentleduck/iam/server/next'
// Route handler wrapper
// `getUserId` is required and has no default: deriving identity from request
// headers is spoofable, so resolve it from your session instead.
export const DELETE = withIamAccess(engine, 'delete', 'post', handler, {
getUserId: async req => (await getServerSession(req))?.user?.id ?? null,
})
// Server component helper
const allowed = await checkIamAccess(engine, userId, 'read', 'post')
// Generate permission map for client hydration
const perms = await getIamPermissions(engine, userId, [
{ action: 'create', resource: 'post' },
{ action: 'delete', resource: 'post' },
])
// Edge middleware
const checkMiddleware = createIamNextMiddleware(engine, {
rules: [{ pattern: '/api/posts', resource: 'post' }],
getUserId: async req => (await getServerSession(req))?.user?.id ?? null,
})
8. React Client Integration
import React from 'react'
import { createIamAccessControl } from '@gentleduck/iam/client/react'
// Create once at app init
export const { AccessProvider, useAccess, usePermissions, Can, Cannot } = createIamAccessControl(React)
// In layout (pass server-generated permissions)
<AccessProvider permissions={perms}>
<App />
</AccessProvider>
// In components
const { can, cannot } = useAccess()
if (can('delete', 'post')) { /* show delete button */ }
// Declarative
<Can action="create" resource="post" fallback={<p>No access</p>}>
<CreatePostButton />
</Can>
<Cannot action="delete" resource="post">
<p>You cannot delete posts</p>
</Cannot>
Also exported by createIamAccessControl:
usePermissions(fetchFn, deps?)-- hook to async-fetch permissions from a server endpoint; returns{ permissions, can, loading, error }AccessContext-- raw React context (rarely needed directly)
Testing Authorization
Use IamMemoryAdapter for unit tests. Seed it with roles, policies, and assignments, then assert with engine.can() or engine.check():
import { createIam } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { describe, expect, it } from 'vitest'
describe('authorization', () => {
const access = createIam({
actions: ['read', 'delete'] as const,
resources: ['post'] as const,
roles: ['viewer', 'admin'] as const,
})
const viewer = access.defineRole('viewer').grantRead('post').build()
const admin = access.defineRole('admin').grantAll('*').build()
const engine = access.createEngine({
adapter: new IamMemoryAdapter({
roles: [viewer, admin],
assignments: { 'u1': ['viewer'], 'u2': ['admin'] },
}),
})
it('viewer can read posts', async () => {
expect(await engine.can('u1', 'read', { type: 'post', attributes: {} })).toBe(true)
})
it('viewer cannot delete posts', async () => {
expect(await engine.can('u1', 'delete', { type: 'post', attributes: {} })).toBe(false)
})
it('admin can delete posts', async () => {
expect(await engine.can('u2', 'delete', { type: 'post', attributes: {} })).toBe(true)
})
})
Use engine.explain() to debug failing assertions -- it returns the full evaluation trace.
Coding Conventions
- Use
createIamfor type-safe builders. Use standalonedefineRole/defineRule/definePolicy/whenonly for untyped or dynamic scenarios. - Always call
.build()to finalize builders -- they return plain data objects. - Roles produce RBAC permissions; policies produce ABAC rules. The engine combines both.
- A deny from any policy is final when using
deny-overrides. - Adapters are async interfaces. Use
IamMemoryAdapterfor tests, implementIamAdapter.IAdapterfor production. - The engine caches roles, policies, subjects, and RBAC-to-policy conversions with configurable TTL.
- Use
engine.explain()for debugging -- it returns a full trace of why a decision was made. - Use
engine.permissions()for batch checks -- it loads data once and evaluates many. - Server integrations follow a consistent pattern:
iamAccessMiddlewarefor global checks,iamGuardfor per-route.
Do Not
- Do NOT import from
dist/paths -- use the package export paths listed above. - Do NOT skip
.build()-- builders are mutable; only the built object is safe to pass around. - Do NOT use
allow-overridesfor restriction policies -- a deny rule will be ignored if any allow matches. - Do NOT hardcode role checks in application code -- use the engine or permission maps instead.
- Do NOT mutate
Role/Policy/Ruleobjects after building -- treat them as immutable. - Do NOT call
engine.explain()in production hot paths -- it is a debug tool with extra overhead. - Do NOT forget to invalidate caches after CRUD operations on roles/policies/subjects.