chapter 3: policies, rules, and conditions
Go beyond roles with attribute-based access control. Write policies with conditions that check resource ownership, time of day, and custom attributes.
Goal
Roles tell you "this user is an editor." They cannot answer "can this editor update this specific post?" For that, you need policies with conditions. By the end of this chapter, BlogDuck will enforce owner-only editing -- editors can only update posts they wrote.
Policies vs Roles
| Roles (RBAC) | Policies (ABAC) |
|---|---|
| "Who is the user?" | "What are the circumstances?" |
| Static permission grants | Dynamic condition checks |
editor can update posts | deny update if not the owner |
| Simple, fast | Flexible, expressive |
Use both together. Roles handle broad access grants; policies handle fine-grained conditions.
Your First Policy
Create an owner-only policy
import { policy } from '@gentleduck/iam'
export const ownerPolicy = policy('owner-restrictions')
.name('Owner Restrictions')
.algorithm('deny-overrides')
.rule('deny-non-owner-update', r => r
.deny()
.on('update', 'delete')
.of('post')
.priority(100)
.when(w => w
.check('resource.attributes.ownerId', 'neq', '$subject.id')
)
)
.build()import { policy } from '@gentleduck/iam'
export const ownerPolicy = policy('owner-restrictions')
.name('Owner Restrictions')
.algorithm('deny-overrides')
.rule('deny-non-owner-update', r => r
.deny()
.on('update', 'delete')
.of('post')
.priority(100)
.when(w => w
.check('resource.attributes.ownerId', 'neq', '$subject.id')
)
)
.build()policy('owner-restrictions')creates a policy with IDowner-restrictions.algorithm('deny-overrides'): if any rule denies, the policy denies.rule('deny-non-owner-update', ...)defines a rule inside the policy.deny()sets this rule's effect to deny.on('update', 'delete')applies to update and delete actions.of('post')applies to thepostresource type.priority(100): higher number = higher priority (relevant forhighest-priorityalgorithm).when(...)is the condition that must be true for this rule to fire'resource.attributes.ownerId'reads the ownerId from the resource'neq'is the "not equal" operator'$subject.id'is resolved at runtime to the requesting user's ID
Add the policy to the adapter
import { ownerPolicy } from './policies'
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'alice': ['viewer'],
'bob': ['editor'],
'charlie': ['admin'],
},
policies: [ownerPolicy],
})import { ownerPolicy } from './policies'
const adapter = new MemoryAdapter({
roles: [viewer, editor, admin],
assignments: {
'alice': ['viewer'],
'bob': ['editor'],
'charlie': ['admin'],
},
policies: [ownerPolicy],
})Test with resource attributes
// Bob (editor) updating his own post -- should be allowed
const ownPost = await engine.can('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
console.log('Bob update own post:', ownPost) // true
// Bob updating someone else's post -- should be denied
const otherPost = await engine.can('bob', 'update', {
type: 'post',
id: 'post-2',
attributes: { ownerId: 'alice' },
})
console.log('Bob update alice post:', otherPost) // false// Bob (editor) updating his own post -- should be allowed
const ownPost = await engine.can('bob', 'update', {
type: 'post',
id: 'post-1',
attributes: { ownerId: 'bob' },
})
console.log('Bob update own post:', ownPost) // true
// Bob updating someone else's post -- should be denied
const otherPost = await engine.can('bob', 'update', {
type: 'post',
id: 'post-2',
attributes: { ownerId: 'alice' },
})
console.log('Bob update alice post:', otherPost) // falseThe ownerId in the resource attributes is compared to $subject.id (which resolves
to 'bob'). When they differ, the deny rule fires.
How Conditions Work
Each condition has three parts:
- Field -- a dot-notation path into the request context (
resource.attributes.ownerId) - Operator -- how to compare (
eq,neq,gt,in,contains, etc.) - Value -- what to compare against (a literal or a
$-variable)
Field Resolution
The resolve() function reads a dot-notation path from the request:
| Path | Resolves To |
|---|---|
subject.id | The requesting user's ID |
subject.roles | The user's role array |
subject.attributes.department | A user attribute |
resource.type | The resource type string |
resource.id | The resource instance ID |
resource.attributes.ownerId | A resource attribute |
environment.ip | Client IP address |
environment.userAgent | Client user agent |
environment.timestamp | Current timestamp |
action | Shorthand for the action string |
scope | Shorthand for the scope string |
Only subject, resource, and environment roots are allowed. Paths like
__proto__, constructor, and prototype are blocked to prevent prototype pollution.
If a field does not exist, it resolves to null. A neq check against a missing field
evaluates to true, so the deny rule fires. Missing data always results in a deny.
All Available Operators
| Operator | Meaning | Example | Type Safety |
|---|---|---|---|
eq | equals | status eq 'published' | any |
neq | not equals | ownerId neq $subject.id | any |
gt | greater than | age gt 18 | numbers only |
gte | greater than or equal | level gte 5 | numbers only |
lt | less than | price lt 100 | numbers only |
lte | less than or equal | priority lte 3 | numbers only |
in | value in array | status in ['draft','review'] | any |
nin | value not in array | role nin ['banned'] | any |
contains | array/string contains | tags contains 'featured' | array or string |
not_contains | does not contain | tags not_contains 'spam' | array or string |
starts_with | string prefix | email starts_with 'admin' | strings only |
ends_with | string suffix | email ends_with '@company.com' | strings only |
matches | regex match | name matches '^[A-Z]' | strings only |
exists | field is not null | deletedAt exists | any (no value needed) |
not_exists | field is null | deletedAt not_exists | any (no value needed) |
subset_of | array subset | roles subset_of ['a','b','c'] | arrays only |
superset_of | array superset | perms superset_of ['read'] | arrays only |
Numeric operators (gt, gte, lt, lte) return false if either operand is not a
number. String operators (starts_with, ends_with, matches) return false if either
operand is not a string. This prevents type coercion bugs.
The matches operator limits patterns to 512 characters and caches compiled regexes
(max 256) to prevent ReDoS attacks.
Dynamic $-Variables
Values starting with $ are resolved from the request context at runtime:
| Variable | Resolves To |
|---|---|
$subject.id | The requesting user's ID |
$subject.roles | The user's role array |
$subject.attributes.X | A user attribute |
$resource.id | The resource ID |
$resource.type | The resource type |
$resource.attributes.X | A resource attribute |
$environment.X | An environment value |
Without $, values are treated as static literals. 'published' is a literal string;
'$subject.id' is resolved at runtime.
The When Builder: Complete API
The when() callback receives a When builder. Here is every method available:
Raw Condition
// The general-purpose method -- all other methods are shortcuts for this
.when(w => w.check('resource.attributes.ownerId', 'neq', '$subject.id'))// The general-purpose method -- all other methods are shortcuts for this
.when(w => w.check('resource.attributes.ownerId', 'neq', '$subject.id'))Shorthand Operators
Instead of .check(field, operator, value), use operator-named methods:
.when(w => w
.eq('resource.attributes.status', 'published') // equals
.neq('resource.attributes.ownerId', '$subject.id') // not equals
.gt('resource.attributes.priority', 5) // greater than
.gte('subject.attributes.level', 3) // greater than or equal
.lt('resource.attributes.price', 100) // less than
.lte('resource.attributes.attempts', 3) // less than or equal
.in('resource.attributes.status', ['draft', 'review']) // value in array
.contains('subject.roles', 'editor') // array contains value
.exists('resource.attributes.publishedAt') // field is not null
.matches('subject.attributes.email', '^admin@') // regex match
).when(w => w
.eq('resource.attributes.status', 'published') // equals
.neq('resource.attributes.ownerId', '$subject.id') // not equals
.gt('resource.attributes.priority', 5) // greater than
.gte('subject.attributes.level', 3) // greater than or equal
.lt('resource.attributes.price', 100) // less than
.lte('resource.attributes.attempts', 3) // less than or equal
.in('resource.attributes.status', ['draft', 'review']) // value in array
.contains('subject.roles', 'editor') // array contains value
.exists('resource.attributes.publishedAt') // field is not null
.matches('subject.attributes.email', '^admin@') // regex match
)Semantic Shortcuts
These handle the field paths automatically:
.when(w => w
// Check if the user owns the resource
.isOwner()
// Equivalent to: .check('resource.attributes.ownerId', 'eq', '$subject.id')
// Check if user has a specific role
.role('admin')
// Equivalent to: .contains('subject.roles', 'admin')
// Check if user has any of these roles
.roles('admin', 'moderator')
// Equivalent to: .check('subject.roles', 'in', ['admin', 'moderator'])
// Check if request is for a specific scope
.scope('acme')
// Equivalent to: .check('scope', 'eq', 'acme')
// Check if request scope is one of these
.scopes('acme', 'globex')
// Equivalent to: .check('scope', 'in', ['acme', 'globex'])
// Check the resource type
.resourceType('post', 'comment')
// Equivalent to: .check('resource.type', 'in', ['post', 'comment'])
// Check a subject attribute
.attr('department', 'eq', 'engineering')
// Equivalent to: .check('subject.attributes.department', 'eq', 'engineering')
// Check a resource attribute
.resourceAttr('status', 'eq', 'published')
// Equivalent to: .check('resource.attributes.status', 'eq', 'published')
// Check an environment value
.env('ip', 'starts_with', '192.168.')
// Equivalent to: .check('environment.ip', 'starts_with', '192.168.')
).when(w => w
// Check if the user owns the resource
.isOwner()
// Equivalent to: .check('resource.attributes.ownerId', 'eq', '$subject.id')
// Check if user has a specific role
.role('admin')
// Equivalent to: .contains('subject.roles', 'admin')
// Check if user has any of these roles
.roles('admin', 'moderator')
// Equivalent to: .check('subject.roles', 'in', ['admin', 'moderator'])
// Check if request is for a specific scope
.scope('acme')
// Equivalent to: .check('scope', 'eq', 'acme')
// Check if request scope is one of these
.scopes('acme', 'globex')
// Equivalent to: .check('scope', 'in', ['acme', 'globex'])
// Check the resource type
.resourceType('post', 'comment')
// Equivalent to: .check('resource.type', 'in', ['post', 'comment'])
// Check a subject attribute
.attr('department', 'eq', 'engineering')
// Equivalent to: .check('subject.attributes.department', 'eq', 'engineering')
// Check a resource attribute
.resourceAttr('status', 'eq', 'published')
// Equivalent to: .check('resource.attributes.status', 'eq', 'published')
// Check an environment value
.env('ip', 'starts_with', '192.168.')
// Equivalent to: .check('environment.ip', 'starts_with', '192.168.')
)| Shortcut | Field Path | Description |
|---|---|---|
.isOwner(field?) | resource.attributes.ownerId | Check resource ownership (custom field optional) |
.role(id) | subject.roles | User has this role |
.roles(...ids) | subject.roles | User has any of these roles |
.scope(id) | scope | Request scope matches |
.scopes(...ids) | scope | Request scope is one of these |
.resourceType(...types) | resource.type | Resource type matches |
.attr(path, op, value) | subject.attributes.{path} | Check user attribute |
.resourceAttr(path, op, value) | resource.attributes.{path} | Check resource attribute |
.env(path, op, value) | environment.{path} | Check environment value |
Custom Owner Field
isOwner() defaults to resource.attributes.ownerId. Pass a custom field path to override:
.when(w => w.isOwner('resource.attributes.authorId')).when(w => w.isOwner('resource.attributes.authorId'))Condition Groups: AND, OR, NOT
By default, all conditions in a .when() are AND-combined. Use nesting for OR and NOT logic:
// ALL must pass (AND) -- the default
.when(w => w
.isOwner()
.resourceAttr('status', 'neq', 'locked')
)
// ANY can pass (OR) -- use .or()
.when(w => w
.or(o => o
.role('admin')
.isOwner()
)
)
// NONE can pass (NOT) -- use .not()
.when(w => w
.not(n => n.role('banned'))
.isOwner()
)
// Explicit AND nesting -- use .and()
.when(w => w
.or(o => o
.role('admin')
.and(a => a
.role('editor')
.isOwner()
)
)
)
// Either admin, OR (editor AND owner)// ALL must pass (AND) -- the default
.when(w => w
.isOwner()
.resourceAttr('status', 'neq', 'locked')
)
// ANY can pass (OR) -- use .or()
.when(w => w
.or(o => o
.role('admin')
.isOwner()
)
)
// NONE can pass (NOT) -- use .not()
.when(w => w
.not(n => n.role('banned'))
.isOwner()
)
// Explicit AND nesting -- use .and()
.when(w => w
.or(o => o
.role('admin')
.and(a => a
.role('editor')
.isOwner()
)
)
)
// Either admin, OR (editor AND owner)Groups can be nested up to 10 levels deep. Deeper nesting returns false (fail closed)
to prevent stack overflow.
Building Standalone Condition Groups
Build condition groups outside of a rule using the when() factory:
import { when } from '@gentleduck/iam'
// Build an ANY group (OR)
const isAdminOrOwner = when()
.role('admin')
.isOwner()
.buildAny()
// Build an ALL group (AND)
const isActiveEditor = when()
.role('editor')
.attr('status', 'eq', 'active')
.buildAll()
// Build a NONE group (NOT)
const notBanned = when()
.role('banned')
.buildNone()import { when } from '@gentleduck/iam'
// Build an ANY group (OR)
const isAdminOrOwner = when()
.role('admin')
.isOwner()
.buildAny()
// Build an ALL group (AND)
const isActiveEditor = when()
.role('editor')
.attr('status', 'eq', 'active')
.buildAll()
// Build a NONE group (NOT)
const notBanned = when()
.role('banned')
.buildNone()These return ConditionGroup objects that can be used in rules.
The Complete RuleBuilder API
Each rule inside a policy is built with a RuleBuilder:
policy('my-policy')
.rule('my-rule', r => r
.allow() // or .deny() -- the rule's effect
.desc('Allow editors to update their own posts') // description
.on('update', 'delete') // which actions this rule applies to
.of('post', 'comment') // which resource types
.priority(100) // numeric priority (for highest-priority algorithm)
.forScope('acme', 'globex') // restrict to specific scopes
.when(w => w.isOwner()) // conditions (AND-combined)
.whenAny(w => w // conditions (OR-combined)
.role('admin')
.isOwner()
)
.meta({ deprecated: false }) // arbitrary metadata
)
.build()policy('my-policy')
.rule('my-rule', r => r
.allow() // or .deny() -- the rule's effect
.desc('Allow editors to update their own posts') // description
.on('update', 'delete') // which actions this rule applies to
.of('post', 'comment') // which resource types
.priority(100) // numeric priority (for highest-priority algorithm)
.forScope('acme', 'globex') // restrict to specific scopes
.when(w => w.isOwner()) // conditions (AND-combined)
.whenAny(w => w // conditions (OR-combined)
.role('admin')
.isOwner()
)
.meta({ deprecated: false }) // arbitrary metadata
)
.build()| Method | Default | Description |
|---|---|---|
.allow() | yes | Rule effect is allow |
.deny() | Rule effect is deny | |
.desc(d) | Human-readable description | |
.on(...actions) | ['*'] | Which actions trigger this rule |
.of(...resources) | ['*'] | Which resource types |
.priority(n) | 10 | Numeric priority (higher wins in highest-priority) |
.forScope(...scopes) | all scopes | Restrict rule to specific scopes |
.when(fn) | no conditions | AND-combined conditions |
.whenAny(fn) | no conditions | OR-combined conditions |
.meta(m) | Arbitrary metadata |
.forScope()
forScope() adds a scope condition merged with your .when() conditions:
.rule('acme-only', r => r
.allow()
.on('manage')
.of('dashboard')
.forScope('acme')
.when(w => w.role('admin'))
)
// Rule fires only when: scope is 'acme' AND user has admin role.rule('acme-only', r => r
.allow()
.on('manage')
.of('dashboard')
.forScope('acme')
.when(w => w.role('admin'))
)
// Rule fires only when: scope is 'acme' AND user has admin roleMultiple scopes use in:
.forScope('acme', 'globex')
// Equivalent to: scope IN ['acme', 'globex'].forScope('acme', 'globex')
// Equivalent to: scope IN ['acme', 'globex'].when() vs .whenAny()
.when(fn)wraps conditions in anallgroup (AND).whenAny(fn)wraps conditions in ananygroup (OR)
// AND: must be owner AND not locked
.when(w => w
.isOwner()
.resourceAttr('status', 'neq', 'locked')
)
// OR: admin OR owner
.whenAny(w => w
.role('admin')
.isOwner()
)// AND: must be owner AND not locked
.when(w => w
.isOwner()
.resourceAttr('status', 'neq', 'locked')
)
// OR: admin OR owner
.whenAny(w => w
.role('admin')
.isOwner()
)Standalone Rules with defineRule()
Create rules outside of a policy and add them later:
import { defineRule } from '@gentleduck/iam'
const ownerCheck = defineRule('owner-check')
.deny()
.on('update', 'delete')
.of('post')
.priority(100)
.when(w => w
.check('resource.attributes.ownerId', 'neq', '$subject.id')
.not(n => n.role('admin'))
)
.build()
// Add to a policy
const myPolicy = policy('my-policy')
.algorithm('deny-overrides')
.addRule(ownerCheck) // add pre-built rule
.rule('other-rule', r => r.deny().on('*').of('secret')) // inline rule
.build()import { defineRule } from '@gentleduck/iam'
const ownerCheck = defineRule('owner-check')
.deny()
.on('update', 'delete')
.of('post')
.priority(100)
.when(w => w
.check('resource.attributes.ownerId', 'neq', '$subject.id')
.not(n => n.role('admin'))
)
.build()
// Add to a policy
const myPolicy = policy('my-policy')
.algorithm('deny-overrides')
.addRule(ownerCheck) // add pre-built rule
.rule('other-rule', r => r.deny().on('*').of('secret')) // inline rule
.build()The Complete PolicyBuilder API
policy('my-policy')
.name('My Policy') // human-readable name (defaults to ID)
.desc('Restricts access to posts') // description
.version(2) // version number
.algorithm('deny-overrides') // combining algorithm
.target({ // scope which requests this policy applies to
actions: ['update', 'delete'],
resources: ['post'],
roles: ['editor'],
})
.rule('rule-1', r => r.deny().on('update').of('post')) // inline rule
.addRule(preBuiltRule) // add pre-built Rule object
.build()policy('my-policy')
.name('My Policy') // human-readable name (defaults to ID)
.desc('Restricts access to posts') // description
.version(2) // version number
.algorithm('deny-overrides') // combining algorithm
.target({ // scope which requests this policy applies to
actions: ['update', 'delete'],
resources: ['post'],
roles: ['editor'],
})
.rule('rule-1', r => r.deny().on('update').of('post')) // inline rule
.addRule(preBuiltRule) // add pre-built Rule object
.build()| Method | Default | Description |
|---|---|---|
.name(n) | policy ID | Human-readable display name |
.desc(d) | Description | |
.version(v) | Version number (for tracking changes) | |
.algorithm(a) | 'deny-overrides' | How rules are combined |
.target(t) | all requests | Scope which requests trigger this policy |
.rule(id, fn) | Add an inline rule via builder callback | |
.addRule(rule) | Add a pre-built Rule object |
Policy Targets
Targets skip an entire policy when the request does not match:
policy('post-restrictions')
.algorithm('deny-overrides')
.target({
actions: ['update', 'delete'], // only evaluate for these actions
resources: ['post'], // only evaluate for these resource types
roles: ['editor'], // only evaluate for users with these roles
})
.rule('deny-non-owner', r => r.deny().on('update').of('post').when(w =>
w.check('resource.attributes.ownerId', 'neq', '$subject.id')
))
.build()policy('post-restrictions')
.algorithm('deny-overrides')
.target({
actions: ['update', 'delete'], // only evaluate for these actions
resources: ['post'], // only evaluate for these resource types
roles: ['editor'], // only evaluate for users with these roles
})
.rule('deny-non-owner', r => r.deny().on('update').of('post').when(w =>
w.check('resource.attributes.ownerId', 'neq', '$subject.id')
))
.build()If a request does not match the target (e.g., action is read), the policy is skipped
and returns the default effect. Target fields are all optional; omitting a field means
"match all":
| Target Field | Effect |
|---|---|
actions | Only evaluate for these actions |
resources | Only evaluate for these resource types |
roles | Only evaluate for users with at least one of these roles |
Combining Algorithms
Each policy has an algorithm that determines how its rules combine:
| Algorithm | Behavior | Use When |
|---|---|---|
deny-overrides | Any deny wins over any allow | Security-critical policies (default) |
allow-overrides | Any allow wins over any deny | Permissive policies, RBAC |
first-match | First matching rule decides | Order-dependent evaluation |
highest-priority | Matching rule with highest priority number wins | Priority-based resolution |
When no rules match, the default effect applies (usually deny).
Use deny-overrides for security-critical policies. The internal __rbac__ policy uses
allow-overrides.
Cross-Policy AND
When multiple policies are present (RBAC + custom), the engine combines them with AND: all policies must allow. Any deny from any policy = overall deny.
The RBAC layer allows because Bob is an editor. The owner policy denies because Bob is not the owner. Overall result: deny.
The engine evaluates policies in order and short-circuits on the first deny.