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.
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 |
You use both together. Roles handle the broad strokes, policies handle the fine print.
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()Let us break this down:
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()-- this rule's effect is 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(...)-- 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
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 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
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 |
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
| 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 |
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:
| 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. 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.')
)| 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 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)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()| 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 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 roleIf 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 anallgroup (AND -- all must pass).whenAny(fn)wraps all conditions in ananygroup (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()| 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 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 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 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.
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.