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.
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:
import { defineRole } from '@gentleduck/iam'
// A viewer can read posts and comments
export const viewer = defineRole('viewer')
.grant('read', 'post')
.grant('read', 'comment')
.build()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:
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'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:
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()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()
| Method | Returns | Use when |
|---|---|---|
engine.can() | boolean (always) | Yes/no is enough |
engine.check() | Decision in dev, boolean in prod | You 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
When you call engine.can('alice', 'read', { type: 'post', attributes: {} }):
- resolveSubject: loads Alice's roles from the adapter —
['viewer']. Also resolves inheritance (Chapter 2) and loads her attributes. - rolesToPolicy: converts her roles into a synthetic policy
__rbac__with theallow-overridesalgorithm. Each permission becomes a rule. - evaluate: runs all policies (
__rbac__plus custom policies from Chapter 3), checking whetherreadonpostmatches any rule. - Decision: the
read:postrule 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) // 1708300000000const 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) // 1708300000000Use 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)
})| Parameter | Default | Description |
|---|---|---|
adapter | required | Data 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. |
cacheTTL | 60 | Cache lifetime in seconds. Set 0 in tests. |
maxCacheSize | 1000 | Max 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