Skip to main content

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.

Loading diagram...

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.

src/lib/access.ts
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.

OptionTypeDefaultMeaning
actionsreadonly string[]requiredActions your application supports
resourcesreadonly string[]requiredResource types your application manages
rolesreadonly string[][]Role IDs; constrains defineRole and assignRole
scopesreadonly string[][]Scope strings for multi-tenant authorization
contextobjectDotPath.IDefaultContextPhantom 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.

src/lib/roles.ts
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.

Step 4: create the engine and run your first check

src/lib/engine.ts
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.

Loading diagram...

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

MethodInputReturns
can(subjectId, action, resource, environment?, scope?)subject IDPromise<boolean> always
check(subjectId, action, resource, environment?, scope?)subject IDPromise<boolean> in production mode, Promise<AccessControl.IDecision> in development
authorize(request)a full IamRequest.IAccessRequest you built yourselfmode-dependent, same as check
explain(subjectId, action, resource, environment?, scope?)subject IDPromise<Explain.IResult>; throws in production mode
permissions(subjectId, checks, environment?, opts?)subject ID plus up to 1024 checksa 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.

src/lib/policies.ts
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.

Step 6: owner-only permissions

grantWhen attaches a condition to a single grant. w.isOwner() is shorthand for resource.attributes.ownerId eq $subject.id.

src/lib/roles.ts
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.

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

src/server.ts
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

src/worker.ts
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

src/posts/posts.controller.ts
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

src/app/api/posts/[id]/route.ts
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
    },
  },
)

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.

src/app/layout.tsx
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>
}
src/lib/access-client.tsx
'use client'

import React from 'react'
import { createIamAccessControl } from '@gentleduck/iam/client/react'

export const { AccessProvider, useAccess, usePermissions, Can, Cannot } = createIamAccessControl(React)
src/components/post-actions.tsx
'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