Skip to main content

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.

Loading diagram...

Policies vs Roles

Roles (RBAC)Policies (ABAC)
"Who is the user?""What are the circumstances?"
Static permission grantsDynamic condition checks
editor can update postsdeny update if not the owner
Simple, fastFlexible, expressive

Use both together. Roles handle broad access grants; policies handle fine-grained conditions.

Your First Policy

Create an owner-only policy

src/policies.ts
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()
src/policies.ts
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 ID owner-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 the post resource type
  • .priority(100): higher number = higher priority (relevant for highest-priority algorithm)
  • .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

src/access.ts
import { ownerPolicy } from './policies'
 
const adapter = new MemoryAdapter({
  roles: [viewer, editor, admin],
  assignments: {
    'alice': ['viewer'],
    'bob': ['editor'],
    'charlie': ['admin'],
  },
  policies: [ownerPolicy],
})
src/access.ts
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

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

The ownerId in the resource attributes is compared to $subject.id (which resolves to 'bob'). When they differ, the deny rule fires.

How Conditions Work

Loading diagram...

Each condition has three parts:

  1. Field -- a dot-notation path into the request context (resource.attributes.ownerId)
  2. Operator -- how to compare (eq, neq, gt, in, contains, etc.)
  3. Value -- what to compare against (a literal or a $-variable)

Field Resolution

The resolve() function reads a dot-notation path from the request:

PathResolves To
subject.idThe requesting user's ID
subject.rolesThe user's role array
subject.attributes.departmentA user attribute
resource.typeThe resource type string
resource.idThe resource instance ID
resource.attributes.ownerIdA resource attribute
environment.ipClient IP address
environment.userAgentClient user agent
environment.timestampCurrent timestamp
actionShorthand for the action string
scopeShorthand 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

OperatorMeaningExampleType Safety
eqequalsstatus eq 'published'any
neqnot equalsownerId neq $subject.idany
gtgreater thanage gt 18numbers only
gtegreater than or equallevel gte 5numbers only
ltless thanprice lt 100numbers only
lteless than or equalpriority lte 3numbers only
invalue in arraystatus in ['draft','review']any
ninvalue not in arrayrole nin ['banned']any
containsarray/string containstags contains 'featured'array or string
not_containsdoes not containtags not_contains 'spam'array or string
starts_withstring prefixemail starts_with 'admin'strings only
ends_withstring suffixemail ends_with '@company.com'strings only
matchesregex matchname matches '^[A-Z]'strings only
existsfield is not nulldeletedAt existsany (no value needed)
not_existsfield is nulldeletedAt not_existsany (no value needed)
subset_ofarray subsetroles subset_of ['a','b','c']arrays only
superset_ofarray supersetperms 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:

VariableResolves To
$subject.idThe requesting user's ID
$subject.rolesThe user's role array
$subject.attributes.XA user attribute
$resource.idThe resource ID
$resource.typeThe resource type
$resource.attributes.XA resource attribute
$environment.XAn 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.')
)
ShortcutField PathDescription
.isOwner(field?)resource.attributes.ownerIdCheck resource ownership (custom field optional)
.role(id)subject.rolesUser has this role
.roles(...ids)subject.rolesUser has any of these roles
.scope(id)scopeRequest scope matches
.scopes(...ids)scopeRequest scope is one of these
.resourceType(...types)resource.typeResource 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)

Loading diagram...

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()
MethodDefaultDescription
.allow()yesRule 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)10Numeric priority (higher wins in highest-priority)
.forScope(...scopes)all scopesRestrict rule to specific scopes
.when(fn)no conditionsAND-combined conditions
.whenAny(fn)no conditionsOR-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 role

Multiple 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 an all group (AND)
  • .whenAny(fn) wraps conditions in an any group (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()
MethodDefaultDescription
.name(n)policy IDHuman-readable display name
.desc(d)Description
.version(v)Version number (for tracking changes)
.algorithm(a)'deny-overrides'How rules are combined
.target(t)all requestsScope 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 FieldEffect
actionsOnly evaluate for these actions
resourcesOnly evaluate for these resource types
rolesOnly evaluate for users with at least one of these roles

Combining Algorithms

Each policy has an algorithm that determines how its rules combine:

Loading diagram...

AlgorithmBehaviorUse When
deny-overridesAny deny wins over any allowSecurity-critical policies (default)
allow-overridesAny allow wins over any denyPermissive policies, RBAC
first-matchFirst matching rule decidesOrder-dependent evaluation
highest-priorityMatching rule with highest priority number winsPriority-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.

Loading diagram...

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.

Checkpoint


Chapter 3 FAQ


Next: Chapter 4: The Engine In Depth