Skip to main content
Search...

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." But 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

You use both together. Roles handle the broad strokes, policies handle the fine print.

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()

Let us break this down:

  • 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() -- this rule's effect is 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(...) -- the condition that must be true for this rule to fire
  • 'resource.attributes.ownerId' -- reads the ownerId from the resource
  • 'neq' -- "not equal" operator
  • '$subject.id' -- 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 key difference is the ownerId in the resource attributes. The condition compares it to $subject.id (which resolves to 'bob') and denies when they are not equal.

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

Security: 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. This means a neq check against a missing field evaluates to true -- the deny rule fires. Missing data always results in a deny, which is the safe default.

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

Operator type safety: 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.

Regex safety: 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. For example, 'published' is a literal string, while '$subject.id' is resolved dynamically.

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 read more naturally and handle the field paths for you:

.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 checking resource.attributes.ownerId. Pass a custom field path:

.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 (all must pass). Use nesting for OR and NOT logic:

// ALL must pass (AND) -- the default
.when(w => w
  .isOwner()
  .resourceAttr('status', 'neq', 'locked')
)
// Both conditions must be true for the rule to fire
 
// ANY can pass (OR) -- use .or()
.when(w => w
  .or(o => o
    .role('admin')
    .isOwner()
  )
)
// Either being admin OR being the owner satisfies the condition
 
// NONE can pass (NOT) -- use .not()
.when(w => w
  .not(n => n.role('banned'))
  .isOwner()
)
// Must NOT have the banned role, AND must be the owner
 
// 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')
)
// Both conditions must be true for the rule to fire
 
// ANY can pass (OR) -- use .or()
.when(w => w
  .or(o => o
    .role('admin')
    .isOwner()
  )
)
// Either being admin OR being the owner satisfies the condition
 
// NONE can pass (NOT) -- use .not()
.when(w => w
  .not(n => n.role('banned'))
  .isOwner()
)
// Must NOT have the banned role, AND must be the owner
 
// 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

You can 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 that is 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

If you pass multiple scopes, it uses 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 all conditions in an all group (AND -- all must pass)
  • .whenAny(fn) wraps all conditions in an any group (OR -- any can pass)
// 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()

You can 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 let you 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 entire policy is skipped and returns the default effect. This improves performance by avoiding unnecessary rule evaluation.

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 in any algorithm, the default effect applies (usually deny).

Use deny-overrides for security-critical policies. The internal __rbac__ policy uses allow-overrides.

Cross-Policy AND

When you have multiple policies (RBAC + your custom ones), the engine combines them with AND:

All policies must allow. Any deny from any policy = overall deny.

Loading diagram...

This is defense-in-depth. The RBAC layer allows because Bob is an editor. But the owner policy denies because Bob is not the owner. Overall result: deny.

The engine evaluates policies in order. As soon as any policy returns deny, evaluation stops (short-circuits) and the overall result is deny.

Checkpoint

Full 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')
      .not(n => n.role('admin'))
    )
  )
  .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')
      .not(n => n.role('admin'))
    )
  )
  .build()

Chapter 3 FAQ

Which is evaluated first -- roles or policies?

Roles are converted to a synthetic __rbac__ policy and evaluated first, followed by your custom policies. They are AND-combined: all must allow for the final result to be allow. The RBAC policy goes first, but the order matters only because the engine short-circuits on the first deny.

What if I forget to pass ownerId in the resource attributes?

The condition field resource.attributes.ownerId resolves to null. The neq operator compares null neq 'bob' which is true, so the deny rule fires. This means forgetting to pass attributes results in a deny -- which is the safe default. You will never accidentally grant access by omitting data.

How do I make admins exempt from the owner restriction?

Add .not(n => n.role('admin')) to the condition. The deny rule now says: "deny if not the owner AND not an admin." Admins skip the deny rule and can edit any post. This is what the checkpoint code shows.

Can I have multiple policies?

Yes. Pass an array of policies to the adapter: policies: [ownerPolicy, timePolicy, ipPolicy]. Each policy is evaluated independently. All must allow for the overall result to be allow. This means each policy adds a layer of protection without interfering with others.

Can I reuse a rule across multiple policies?

Yes. Use defineRule() to create a standalone rule, then add it to any policy with .addRule(rule). The rule is a plain data object so it can be shared freely.

When should I use .when() vs .whenAny()?

Use .when() when ALL conditions must be true (AND logic). Use .whenAny() when ANY condition being true is sufficient (OR logic). For complex combinations, nest .and(), .or(), and .not() inside a .when() block. The most common pattern is .when() with an inner .not() for exemptions.

What are policy targets used for?

Targets are a performance optimization. They let the engine skip an entire policy when the request clearly does not match (wrong action, wrong resource type, or user lacks the required role). Without targets, every rule in the policy is evaluated on every request. With targets, the policy is skipped entirely when the target does not match, reducing evaluation time.

What does .forScope() do on a rule?

.forScope('acme') adds a scope condition to the rule so it only fires when the request scope is acme. You can pass multiple scopes: .forScope('acme', 'globex'). The scope condition is merged with your .when() conditions using AND -- both must pass for the rule to fire.

Can I restrict access based on IP address or time?

Yes. Use environment conditions. Pass an environment object when checking permissions: engine.can(userId, action, resource, { ip: '192.168.1.1', timestamp: Date.now() }). Then use .env('ip', 'starts_with', '192.168.') or .env('timestamp', 'lt', cutoffTime) in your conditions. The extractEnvironment() helper in server integrations (Chapter 6) populates this automatically.


Next: Chapter 4: The Engine In Depth