Skip to main content

chapter 1: your first permission check

Install duck-iam, define your first role, create an engine, and run your first permission check, all in under 20 lines of code.

Goal

By the end of this chapter, Alice can read blog posts but not delete them.

Loading diagram...

Step by Step

Install duck-iam


npm install @gentleduck/iam

npm install @gentleduck/iam

Zero runtime dependencies. Works with npm, yarn, pnpm, and bun.

Define a role

Create src/access.ts:

src/access.ts
import { defineRole } from '@gentleduck/iam'
 
// A viewer can read posts and comments
export const viewer = defineRole('viewer')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()
src/access.ts
import { defineRole } from '@gentleduck/iam'
 
// A viewer can read posts and comments
export const viewer = defineRole('viewer')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()

defineRole() returns a builder. Chain .grant(action, resource) to add permissions. .build() returns a plain Role object — serializable, no methods, no hidden state.

Create an adapter and engine

The engine needs an adapter to load data (roles, policies, assignments). MemoryAdapter stores everything in memory:

src/access.ts
import { defineRole, Engine } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
 
export const viewer = defineRole('viewer')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()
 
const adapter = new MemoryAdapter({
  roles: [viewer],
  assignments: {
    'alice': ['viewer'],
  },
})
 
export const engine = new Engine({ adapter }) // defaults to mode: 'development'
src/access.ts
import { defineRole, Engine } from '@gentleduck/iam'
import { MemoryAdapter } from '@gentleduck/iam/adapters/memory'
 
export const viewer = defineRole('viewer')
  .grant('read', 'post')
  .grant('read', 'comment')
  .build()
 
const adapter = new MemoryAdapter({
  roles: [viewer],
  assignments: {
    'alice': ['viewer'],
  },
})
 
export const engine = new Engine({ adapter }) // defaults to mode: 'development'

assignments maps subject IDs to role IDs. A subject can have several roles: 'alice': ['viewer', 'commenter'].

Run your first permission check

Create src/main.ts:

src/main.ts
import { engine } from './access'
 
async function main() {
  // Can Alice read a post?
  const canRead = await engine.can('alice', 'read', {
    type: 'post',
    attributes: {},
  })
  console.log('Alice can read post:', canRead)
  // Alice can read post: true
 
  // Can Alice delete a post?
  const canDelete = await engine.can('alice', 'delete', {
    type: 'post',
    attributes: {},
  })
  console.log('Alice can delete post:', canDelete)
  // Alice can delete post: false
}
 
main()
src/main.ts
import { engine } from './access'
 
async function main() {
  // Can Alice read a post?
  const canRead = await engine.can('alice', 'read', {
    type: 'post',
    attributes: {},
  })
  console.log('Alice can read post:', canRead)
  // Alice can read post: true
 
  // Can Alice delete a post?
  const canDelete = await engine.can('alice', 'delete', {
    type: 'post',
    attributes: {},
  })
  console.log('Alice can delete post:', canDelete)
  // Alice can delete post: false
}
 
main()

Run it:


npx tsx src/main.ts

npx tsx src/main.ts

engine.can() vs engine.check()

MethodReturnsUse when
engine.can()boolean (always)Yes/no is enough
engine.check()Decision in dev, boolean in prodYou need reason, timing, deciding rule
// Simple boolean -- most common
const allowed = await engine.can('alice', 'read', { type: 'post', attributes: {} })
// true
 
// Full decision object (in development mode -- returns boolean in production mode)
const decision = await engine.check('alice', 'read', { type: 'post', attributes: {} })
// { allowed: true, effect: 'allow', reason: '...', duration: 0.12, ... }
// Simple boolean -- most common
const allowed = await engine.can('alice', 'read', { type: 'post', attributes: {} })
// true
 
// Full decision object (in development mode -- returns boolean in production mode)
const decision = await engine.check('alice', 'read', { type: 'post', attributes: {} })
// { allowed: true, effect: 'allow', reason: '...', duration: 0.12, ... }

Both methods share the same signature:

engine.can(subjectId, action, resource, environment?, scope?)
engine.check(subjectId, action, resource, environment?, scope?)
engine.can(subjectId, action, resource, environment?, scope?)
engine.check(subjectId, action, resource, environment?, scope?)

environment and scope are optional and covered later.

What Just Happened

Loading diagram...

When you call engine.can('alice', 'read', { type: 'post', attributes: {} }):

  1. resolveSubject: loads Alice's roles from the adapter — ['viewer']. Also resolves inheritance (Chapter 2) and loads her attributes.
  2. rolesToPolicy: converts her roles into a synthetic policy __rbac__ with the allow-overrides algorithm. Each permission becomes a rule.
  3. evaluate: runs all policies (__rbac__ plus custom policies from Chapter 3), checking whether read on post matches any rule.
  4. Decision: the read:post rule matches. Effect: allow.

For delete, no rule matches, so the engine returns the default effect: deny.

The rbac Synthetic Policy

Role definitions don't run directly. The engine converts them into a standard ABAC policy:

// Your role:
defineRole('viewer').grant('read', 'post').build()
 
// Becomes this policy internally:
{
  id: '__rbac__',
  name: 'RBAC Policies',
  algorithm: 'allow-overrides',
  rules: [{
    id: 'rbac.viewer.read.post.0',
    effect: 'allow',
    actions: ['read'],
    resources: ['post'],
    conditions: { all: [
      { field: 'subject.roles', operator: 'contains', value: 'viewer' }
    ]},
  }],
}
// Your role:
defineRole('viewer').grant('read', 'post').build()
 
// Becomes this policy internally:
{
  id: '__rbac__',
  name: 'RBAC Policies',
  algorithm: 'allow-overrides',
  rules: [{
    id: 'rbac.viewer.read.post.0',
    effect: 'allow',
    actions: ['read'],
    resources: ['post'],
    conditions: { all: [
      { field: 'subject.roles', operator: 'contains', value: 'viewer' }
    ]},
  }],
}

RBAC and ABAC share one evaluation pipeline. Roles are shorthand for policies.

The Decision Object

engine.check() returns a Decision:

interface Decision {
  allowed: boolean        // true or false
  effect: 'allow' | 'deny'  // same as allowed, as a string
  rule?: Rule             // the rule that decided (if any)
  policy?: string         // the policy ID that decided
  reason: string          // human-readable explanation
  duration: number        // how long evaluation took (ms)
  timestamp: number       // when the decision was made (Date.now())
}
interface Decision {
  allowed: boolean        // true or false
  effect: 'allow' | 'deny'  // same as allowed, as a string
  rule?: Rule             // the rule that decided (if any)
  policy?: string         // the policy ID that decided
  reason: string          // human-readable explanation
  duration: number        // how long evaluation took (ms)
  timestamp: number       // when the decision was made (Date.now())
}
const decision = await engine.check('alice', 'read', { type: 'post', attributes: {} })
 
console.log(decision.allowed)    // true
console.log(decision.effect)     // 'allow'
console.log(decision.reason)     // 'Allowed by rule "rbac.viewer.read.post.0"'
console.log(decision.policy)     // '__rbac__'
console.log(decision.duration)   // 0.12 (milliseconds)
console.log(decision.timestamp)  // 1708300000000
const decision = await engine.check('alice', 'read', { type: 'post', attributes: {} })
 
console.log(decision.allowed)    // true
console.log(decision.effect)     // 'allow'
console.log(decision.reason)     // 'Allowed by rule "rbac.viewer.read.post.0"'
console.log(decision.policy)     // '__rbac__'
console.log(decision.duration)   // 0.12 (milliseconds)
console.log(decision.timestamp)  // 1708300000000

Use decision.duration for performance monitoring, decision.reason for debugging, and decision.policy / decision.rule to trace the deciding rule.

The Resource Object

interface Resource {
  type: string           // the resource type (matches rule resources)
  id?: string            // optional: specific resource instance
  attributes: Attributes // resource metadata for conditions
}
interface Resource {
  type: string           // the resource type (matches rule resources)
  id?: string            // optional: specific resource instance
  attributes: Attributes // resource metadata for conditions
}
// Minimal resource (just the type)
{ type: 'post', attributes: {} }
 
// With a specific instance ID
{ type: 'post', id: 'post-123', attributes: {} }
 
// With metadata for conditions (Chapter 3)
{
  type: 'post',
  id: 'post-123',
  attributes: {
    ownerId: 'alice',
    status: 'published',
    tags: ['featured', 'tech'],
  },
}
// Minimal resource (just the type)
{ type: 'post', attributes: {} }
 
// With a specific instance ID
{ type: 'post', id: 'post-123', attributes: {} }
 
// With metadata for conditions (Chapter 3)
{
  type: 'post',
  id: 'post-123',
  attributes: {
    ownerId: 'alice',
    status: 'published',
    tags: ['featured', 'tech'],
  },
}

type matches against role permissions. id identifies a specific instance. attributes feed policy conditions (Chapter 3) — pass {} for now.

Resource types support hierarchical matching with dots: a permission on dashboard also covers dashboard.users and dashboard.settings. See Chapter 5.

Engine Configuration

const engine = new Engine({
  adapter,                    // required: where to load data from
  mode: 'development',        // optional: 'development' | 'production' (default: 'development')
  defaultEffect: 'deny',     // optional: what to return when no rules match (default: 'deny')
  cacheTTL: 60,              // optional: cache lifetime in seconds (default: 60)
  maxCacheSize: 1000,         // optional: max cached subjects (default: 1000)
  hooks: { ... },             // optional: lifecycle hooks (Chapter 4)
})
const engine = new Engine({
  adapter,                    // required: where to load data from
  mode: 'development',        // optional: 'development' | 'production' (default: 'development')
  defaultEffect: 'deny',     // optional: what to return when no rules match (default: 'deny')
  cacheTTL: 60,              // optional: cache lifetime in seconds (default: 60)
  maxCacheSize: 1000,         // optional: max cached subjects (default: 1000)
  hooks: { ... },             // optional: lifecycle hooks (Chapter 4)
})
ParameterDefaultDescription
adapterrequiredData source for roles, policies, assignments.
mode'development''development' or 'production' — controls verbosity and debug behavior.
defaultEffect'deny'What to return when no rule matches. Stay with 'deny' for security.
cacheTTL60Cache lifetime in seconds. Set 0 in tests.
maxCacheSize1000Max subjects cached in memory. LRU eviction.
hooks{}Lifecycle hooks for enrichment, logging, error handling (Chapter 4).

Defaults are fine for now.

Checkpoint

Your project should look like this:

blogduck/
  src/
    access.ts    -- role + adapter + engine
    main.ts      -- permission checks
  package.json
  tsconfig.json

Chapter 1 FAQ


Next: Chapter 2: Role Hierarchies