Skip to main content
Search...

policies and rules

Build ABAC policies and rules with conditions, operators, the When builder, $-variable references, nested logic, targets, and combining algorithms.

When to Use Policies

Roles handle the common case: "editors can update posts." But some access control requirements go beyond what roles can express:

Loading diagram...

  • Time-based restrictions -- deny writes on weekends or outside business hours.
  • IP/geo-fencing -- only allow access from trusted networks.
  • Cross-attribute checks -- allow updates only when the subject's department matches the resource's department.
  • Dynamic deny rules -- block specific users or flag suspicious behavior without changing role assignments.
  • Maintenance mode -- deny all writes globally when a feature flag is set.

For these cases, define ABAC policies. They go through the same evaluation pipeline as roles, and the engine AND-combines them: a deny from any policy is final.

Building Policies

Use the policy() builder to create policies:

import { policy } from '@gentleduck/iam'
 
const weekendDeny = policy('deny-weekends')
  .name('Deny on Weekends')
  .desc('Block all write operations on weekends')
  .version(1)
  .algorithm('deny-overrides')
  .rule('r-deny-weekends', r => r
    .deny()
    .on('create', 'update', 'delete')
    .of('*')
    .when(w => w.env('dayOfWeek', 'in', [0, 6]))
  )
  .build()
import { policy } from '@gentleduck/iam'
 
const weekendDeny = policy('deny-weekends')
  .name('Deny on Weekends')
  .desc('Block all write operations on weekends')
  .version(1)
  .algorithm('deny-overrides')
  .rule('r-deny-weekends', r => r
    .deny()
    .on('create', 'update', 'delete')
    .of('*')
    .when(w => w.env('dayOfWeek', 'in', [0, 6]))
  )
  .build()

Policy builder methods

MethodDescription
name(n)Human-readable name for the policy.
desc(d)Optional description.
version(v)Version number for tracking changes.
algorithm(a)Combining algorithm: 'deny-overrides', 'allow-overrides', 'first-match', 'highest-priority'. Defaults to 'deny-overrides'.
target(t)Scope the policy to specific actions, resources, or roles.
rule(id, fn)Add a rule using an inline builder.
addRule(rule)Add a pre-built Rule object.
build()Produce the final Policy object.

Building Rules

Rules are the individual statements inside a policy. Each rule has an effect (allow or deny), a set of actions and resources it applies to, a priority, and conditions.

Inline rules

The most common approach is defining rules inline within a policy:

const myPolicy = policy('content-policy')
  .name('Content Policy')
  .algorithm('deny-overrides')
  .rule('allow-read', r => r
    .allow()
    .on('read')
    .of('post', 'comment')
  )
  .rule('owner-edit', r => r
    .allow()
    .on('update', 'delete')
    .of('post')
    .when(w => w.isOwner())
  )
  .rule('block-banned', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w.attr('status', 'eq', 'banned'))
  )
  .build()
const myPolicy = policy('content-policy')
  .name('Content Policy')
  .algorithm('deny-overrides')
  .rule('allow-read', r => r
    .allow()
    .on('read')
    .of('post', 'comment')
  )
  .rule('owner-edit', r => r
    .allow()
    .on('update', 'delete')
    .of('post')
    .when(w => w.isOwner())
  )
  .rule('block-banned', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w.attr('status', 'eq', 'banned'))
  )
  .build()

Standalone rules with defineRule

You can also define rules separately and add them to policies:

import { defineRule } from '@gentleduck/iam'
 
const ownerOnly = defineRule('owner-only')
  .allow()
  .on('update', 'delete')
  .of('post')
  .priority(20)
  .when(w => w.isOwner())
  .build()
 
const maintenanceDeny = defineRule('maintenance-deny')
  .deny()
  .on('create', 'update', 'delete')
  .of('*')
  .priority(100)
  .when(w => w.env('maintenanceMode', 'eq', true))
  .build()
 
const myPolicy = policy('my-policy')
  .name('My Policy')
  .algorithm('highest-priority')
  .addRule(ownerOnly)
  .addRule(maintenanceDeny)
  .build()
import { defineRule } from '@gentleduck/iam'
 
const ownerOnly = defineRule('owner-only')
  .allow()
  .on('update', 'delete')
  .of('post')
  .priority(20)
  .when(w => w.isOwner())
  .build()
 
const maintenanceDeny = defineRule('maintenance-deny')
  .deny()
  .on('create', 'update', 'delete')
  .of('*')
  .priority(100)
  .when(w => w.env('maintenanceMode', 'eq', true))
  .build()
 
const myPolicy = policy('my-policy')
  .name('My Policy')
  .algorithm('highest-priority')
  .addRule(ownerOnly)
  .addRule(maintenanceDeny)
  .build()

Rule builder methods

MethodDescription
allow()Set the effect to allow. This is the default.
deny()Set the effect to deny.
on(...actions)Actions this rule applies to. Defaults to ['*'].
of(...resources)Resources this rule applies to. Defaults to ['*'].
priority(p)Numeric priority. Higher values win with highest-priority algorithm. Defaults to 10.
desc(d)Optional description.
when(fn)Conditions that must ALL be true (AND logic).
whenAny(fn)Conditions where ANY can be true (OR logic).
forScope(...scopes)Restrict this rule to specific scopes. Composes with when().
meta(m)Arbitrary metadata.
build()Produce the final Rule object.

Wildcards

Both actions and resources support wildcards:

// All actions on all resources
r.on('*').of('*')
 
// All actions on posts
r.on('*').of('post')
 
// Read all resources
r.on('read').of('*')
// All actions on all resources
r.on('*').of('*')
 
// All actions on posts
r.on('*').of('post')
 
// Read all resources
r.on('read').of('*')

Resources also support hierarchical matching. A rule targeting "dashboard" will match requests for "dashboard.users" and "dashboard.users.settings".

Conditions: The When Builder

The When builder is how you define conditions for rules (and for grantWhen() on roles). By default, all conditions added to a When builder are combined with AND -- every condition must be true.

Raw condition check

The most general method is check(field, operator, value):

.when(w => w
  .check('subject.attributes.age', 'gte', 18)
  .check('resource.attributes.rating', 'neq', 'restricted')
)
.when(w => w
  .check('subject.attributes.age', 'gte', 18)
  .check('resource.attributes.rating', 'neq', 'restricted')
)

Shorthand operator methods

The builder provides named methods for the most common operators:

.when(w => w
  .eq('subject.id', 'user-1')           // field == value
  .neq('resource.attributes.status', 'archived')  // field != value
  .gt('subject.attributes.age', 18)     // field > value
  .gte('subject.attributes.level', 5)   // field >= value
  .lt('resource.attributes.price', 100) // field < value
  .lte('subject.attributes.risk', 3)    // field <= value
  .in('subject.attributes.role', ['admin', 'editor'])  // field in [values]
  .contains('subject.roles', 'admin')   // array contains value / string contains substring
  .exists('resource.attributes.ownerId')  // field is not null/undefined
  .matches('resource.attributes.email', '^.*@company\\.com$')  // regex match
)
.when(w => w
  .eq('subject.id', 'user-1')           // field == value
  .neq('resource.attributes.status', 'archived')  // field != value
  .gt('subject.attributes.age', 18)     // field > value
  .gte('subject.attributes.level', 5)   // field >= value
  .lt('resource.attributes.price', 100) // field < value
  .lte('subject.attributes.risk', 3)    // field <= value
  .in('subject.attributes.role', ['admin', 'editor'])  // field in [values]
  .contains('subject.roles', 'admin')   // array contains value / string contains substring
  .exists('resource.attributes.ownerId')  // field is not null/undefined
  .matches('resource.attributes.email', '^.*@company\\.com$')  // regex match
)

Semantic shortcuts

These methods provide domain-specific shortcuts that resolve to the correct field paths:

.when(w => w
  // Role checks
  .role('admin')                 // subject.roles contains "admin"
  .roles('admin', 'editor')     // subject.roles in ["admin", "editor"]
 
  // Scope checks
  .scope('org-1')               // scope eq "org-1"
  .scopes('org-1', 'org-2')    // scope in ["org-1", "org-2"]
 
  // Ownership check
  .isOwner()                    // resource.attributes.ownerId eq $subject.id
  .isOwner('resource.attributes.createdBy')  // custom owner field
 
  // Resource type check
  .resourceType('post', 'comment')  // resource.type in ["post", "comment"]
 
  // Attribute shortcuts
  .attr('department', 'eq', 'engineering')        // subject.attributes.department eq "engineering"
  .resourceAttr('status', 'eq', 'published')      // resource.attributes.status eq "published"
  .env('ip', 'eq', '10.0.0.1')                   // environment.ip eq "10.0.0.1"
)
.when(w => w
  // Role checks
  .role('admin')                 // subject.roles contains "admin"
  .roles('admin', 'editor')     // subject.roles in ["admin", "editor"]
 
  // Scope checks
  .scope('org-1')               // scope eq "org-1"
  .scopes('org-1', 'org-2')    // scope in ["org-1", "org-2"]
 
  // Ownership check
  .isOwner()                    // resource.attributes.ownerId eq $subject.id
  .isOwner('resource.attributes.createdBy')  // custom owner field
 
  // Resource type check
  .resourceType('post', 'comment')  // resource.type in ["post", "comment"]
 
  // Attribute shortcuts
  .attr('department', 'eq', 'engineering')        // subject.attributes.department eq "engineering"
  .resourceAttr('status', 'eq', 'published')      // resource.attributes.status eq "published"
  .env('ip', 'eq', '10.0.0.1')                   // environment.ip eq "10.0.0.1"
)

All Condition Operators

Here is every operator with its behavior and an example:

OperatorDescriptionExample
eqStrict equality (===)w.eq('subject.id', 'user-1')
neqStrict inequality (!==)w.neq('resource.attributes.status', 'deleted')
gtGreater than (numbers only)w.gt('subject.attributes.age', 18)
gteGreater than or equal (numbers only)w.gte('resource.attributes.priority', 5)
ltLess than (numbers only)w.lt('resource.attributes.price', 1000)
lteLess than or equal (numbers only)w.lte('subject.attributes.riskScore', 3)
inValue is in the given array. If field is an array, checks if any element overlaps.w.in('subject.attributes.tier', ['pro', 'enterprise'])
ninValue is NOT in the given array.w.check('subject.attributes.status', 'nin', ['banned', 'suspended'])
containsArray contains the value, or string contains the substring.w.contains('subject.roles', 'admin')
not_containsArray does NOT contain the value, or string does NOT contain the substring.w.check('subject.attributes.tags', 'not_contains', 'blocked')
starts_withString starts with the given prefix.w.check('resource.attributes.path', 'starts_with', '/admin')
ends_withString ends with the given suffix.w.check('resource.attributes.email', 'ends_with', '@company.com')
matchesString matches a regular expression. Patterns longer than 512 characters return false (ReDoS protection). Invalid regex patterns return false. Patterns are cached in a 256-entry LRU for performance.w.matches('resource.attributes.slug', '^[a-z0-9-]+$')
existsField is not null and not undefined. The value parameter is ignored.w.exists('resource.attributes.publishedAt')
not_existsField is null or undefined. The value parameter is ignored.w.check('resource.attributes.deletedAt', 'not_exists')
subset_ofEvery element in the field array exists in the value array. Both must be arrays.w.check('subject.attributes.permissions', 'subset_of', ['read', 'write', 'admin'])
superset_ofEvery element in the value array exists in the field array. Both must be arrays.w.check('subject.roles', 'superset_of', ['viewer', 'commenter'])

Operator edge cases

A few behaviors to be aware of:

  • Numeric operators (gt, gte, lt, lte): both the field value and the condition value must be numbers. If either is not a number, the condition returns false.
  • String operators (starts_with, ends_with, matches): both must be strings. Non-string values return false.
  • in with arrays: if the field is an array (like subject.roles), the operator checks whether any element in the field array exists in the value array (overlap check). If the field is a scalar, it checks whether the scalar is a member of the value array.
  • contains with strings vs arrays: if the field is an array, uses Array.includes(). If the field is a string, uses String.includes(). Other types return false.
  • subset_of / superset_of: both field and value must be arrays. Non-array values return false.
  • Missing fields: if a field path resolves to null or undefined, comparison operators (eq, gt, etc.) compare against null. Use exists / not_exists to check presence.

Field Resolution

Conditions reference fields using dot-notation paths. The engine resolves these paths against the AccessRequest at evaluation time.

Supported paths

PathResolves to
subject.idThe subject's ID string.
subject.rolesThe subject's roles array.
subject.attributes.<key>A subject attribute. Nest as deep as needed.
resource.typeThe resource type string.
resource.idThe resource instance ID.
resource.attributes.<key>A resource attribute.
environment.<key>An environment value (ip, userAgent, timestamp, or custom).
actionShorthand for the action string on the request.
scopeShorthand for the scope string on the request.

Security

The resolver only allows traversal under subject, resource, and environment. Access to __proto__, constructor, and prototype is blocked to prevent prototype pollution.

$-Variable References

Sometimes a condition needs to compare two fields from the request -- for example, checking if the resource owner matches the current subject. Dollar-prefixed values are resolved at evaluation time instead of being treated as literals.

// This checks: resource.attributes.ownerId === request.subject.id
.when(w => w.check('resource.attributes.ownerId', 'eq', '$subject.id'))
// This checks: resource.attributes.ownerId === request.subject.id
.when(w => w.check('resource.attributes.ownerId', 'eq', '$subject.id'))

The $subject.id string is not compared literally. At eval time, the engine strips the $ prefix and resolves subject.id from the request, then compares the result.

The isOwner() shortcut uses this internally:

// These are equivalent:
.when(w => w.isOwner())
.when(w => w.check('resource.attributes.ownerId', 'eq', '$subject.id'))
// These are equivalent:
.when(w => w.isOwner())
.when(w => w.check('resource.attributes.ownerId', 'eq', '$subject.id'))

You can use $-references with any resolvable path:

// Resource department must match subject department
.when(w => w.check(
  'resource.attributes.department',
  'eq',
  '$subject.attributes.department'
))
 
// Resource scope must match request scope
.when(w => w.check('resource.attributes.scope', 'eq', '$scope'))
// Resource department must match subject department
.when(w => w.check(
  'resource.attributes.department',
  'eq',
  '$subject.attributes.department'
))
 
// Resource scope must match request scope
.when(w => w.check('resource.attributes.scope', 'eq', '$scope'))

Nesting Conditions (and/or/not)

By default, the When builder combines all conditions with AND. For more complex logic, use and(), or(), and not() to create nested groups.

Loading diagram...

OR -- any condition must be true

.when(w => w
  .or(w => w
    .role('admin')
    .isOwner()
  )
)
// Allowed if the subject is an admin OR the owner
.when(w => w
  .or(w => w
    .role('admin')
    .isOwner()
  )
)
// Allowed if the subject is an admin OR the owner

AND -- all conditions must be true (explicit nesting)

.when(w => w
  .and(w => w
    .attr('department', 'eq', 'engineering')
    .attr('level', 'gte', 5)
  )
)
// Allowed if subject is in engineering AND level >= 5
.when(w => w
  .and(w => w
    .attr('department', 'eq', 'engineering')
    .attr('level', 'gte', 5)
  )
)
// Allowed if subject is in engineering AND level >= 5

NOT -- none of the conditions must be true

.when(w => w
  .not(w => w
    .attr('status', 'eq', 'banned')
    .attr('status', 'eq', 'suspended')
  )
)
// Allowed if subject is NOT banned AND NOT suspended
.when(w => w
  .not(w => w
    .attr('status', 'eq', 'banned')
    .attr('status', 'eq', 'suspended')
  )
)
// Allowed if subject is NOT banned AND NOT suspended

Composing nested groups

You can mix and nest these arbitrarily:

const complexRule = defineRule('complex-access')
  .allow()
  .on('update')
  .of('post')
  .when(w => w
    // Must not be banned
    .not(w => w.attr('status', 'eq', 'banned'))
    // AND must satisfy one of these
    .or(w => w
      .role('admin')
      .and(w => w
        .isOwner()
        .resourceAttr('status', 'neq', 'locked')
      )
    )
  )
  .build()
 
// Logic: NOT banned AND (admin OR (owner AND post not locked))
const complexRule = defineRule('complex-access')
  .allow()
  .on('update')
  .of('post')
  .when(w => w
    // Must not be banned
    .not(w => w.attr('status', 'eq', 'banned'))
    // AND must satisfy one of these
    .or(w => w
      .role('admin')
      .and(w => w
        .isOwner()
        .resourceAttr('status', 'neq', 'locked')
      )
    )
  )
  .build()
 
// Logic: NOT banned AND (admin OR (owner AND post not locked))

Here is how the engine evaluates this condition tree:

Loading diagram...

Nesting is limited to 10 levels deep. If exceeded, the condition evaluates to false (fail closed).

Empty condition groups

If a condition group has an empty array, the result follows standard logic conventions:

  • { all: [] } -- true (vacuous truth: all zero conditions are satisfied).
  • { any: [] } -- false (no conditions to satisfy).
  • { none: [] } -- true (no conditions violated).

whenAny for top-level OR

If you want top-level OR instead of AND, use whenAny():

const rule = defineRule('flexible-access')
  .allow()
  .on('read')
  .of('post')
  .whenAny(w => w
    .resourceAttr('visibility', 'eq', 'public')
    .role('admin')
    .isOwner()
  )
  .build()
 
// Allowed if the post is public OR subject is admin OR subject is owner
const rule = defineRule('flexible-access')
  .allow()
  .on('read')
  .of('post')
  .whenAny(w => w
    .resourceAttr('visibility', 'eq', 'public')
    .role('admin')
    .isOwner()
  )
  .build()
 
// Allowed if the post is public OR subject is admin OR subject is owner

Policy Targets

Targets let you scope an entire policy to specific actions, resources, or roles. If a request does not match the targets, the policy is skipped entirely -- its rules are not evaluated.

Loading diagram...

const adminPolicy = policy('admin-only')
  .name('Admin-Only Policy')
  .target({
    roles: ['admin', 'super-admin'],
  })
  .algorithm('deny-overrides')
  .rule('allow-admin-all', r => r.allow().on('*').of('*'))
  .build()
 
// This policy is only evaluated for subjects with admin or super-admin role.
// For everyone else, it is skipped.
const adminPolicy = policy('admin-only')
  .name('Admin-Only Policy')
  .target({
    roles: ['admin', 'super-admin'],
  })
  .algorithm('deny-overrides')
  .rule('allow-admin-all', r => r.allow().on('*').of('*'))
  .build()
 
// This policy is only evaluated for subjects with admin or super-admin role.
// For everyone else, it is skipped.

Target fields

FieldDescription
actionsOnly evaluate this policy if the request action matches one of these.
resourcesOnly evaluate this policy if the request resource matches one of these.
rolesOnly evaluate this policy if the subject has one of these roles.

All fields are optional. If a field is not set, it matches everything. All set fields must match (AND logic).

const writePolicy = policy('write-restrictions')
  .name('Write Restrictions')
  .target({
    actions: ['create', 'update', 'delete'],
    resources: ['post', 'comment'],
  })
  .algorithm('deny-overrides')
  .rule('business-hours', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w
      .or(w => w
        .env('hour', 'lt', 9)
        .env('hour', 'gte', 17)
      )
    )
  )
  .build()
 
// Only applies to write operations on posts and comments.
// Read operations and other resource types are not affected.
const writePolicy = policy('write-restrictions')
  .name('Write Restrictions')
  .target({
    actions: ['create', 'update', 'delete'],
    resources: ['post', 'comment'],
  })
  .algorithm('deny-overrides')
  .rule('business-hours', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w
      .or(w => w
        .env('hour', 'lt', 9)
        .env('hour', 'gte', 17)
      )
    )
  )
  .build()
 
// Only applies to write operations on posts and comments.
// Read operations and other resource types are not affected.

Why targets matter

When a policy is skipped due to target mismatch, the engine uses the defaultEffect for that policy's contribution to the overall decision. Since the default is deny, you might wonder if skipping a policy causes a deny. The answer depends on the cross-policy combination:

  • If a skipped policy returns defaultEffect: "deny", that will cause an overall deny. This is the secure default.
  • To make a policy truly optional (skip without penalty), use targets to ensure the policy only participates when relevant, and ensure at least one other policy allows.

In practice, targets are most useful for policies that add restrictions (deny rules). A policy with deny rules and targets effectively says: "for these specific actions/resources/roles, apply these additional restrictions."

Combining Algorithms: Detailed Reference

Loading diagram...

deny-overrides

The default and most conservative algorithm. Any deny wins over any allow.

const p = policy('strict')
  .algorithm('deny-overrides')
  .rule('allow-read', r => r.allow().on('read').of('post'))
  .rule('deny-drafts', r => r
    .deny()
    .on('read')
    .of('post')
    .when(w => w.resourceAttr('status', 'eq', 'draft'))
  )
  .build()
const p = policy('strict')
  .algorithm('deny-overrides')
  .rule('allow-read', r => r.allow().on('read').of('post'))
  .rule('deny-drafts', r => r
    .deny()
    .on('read')
    .of('post')
    .when(w => w.resourceAttr('status', 'eq', 'draft'))
  )
  .build()

Evaluation logic:

  1. Find all matching rules.
  2. If any has effect: "deny", the policy result is deny.
  3. Else if any has effect: "allow", the policy result is allow.
  4. Else fall back to defaultEffect.

Use this when security is the priority. A single deny rule can block access regardless of how many allow rules match.

allow-overrides

The inverse -- any allow wins over any deny. This is the algorithm used by the auto-generated RBAC policy.

const p = policy('permissive')
  .algorithm('allow-overrides')
  .rule('deny-default', r => r.deny().on('*').of('*'))
  .rule('vip-access', r => r
    .allow()
    .on('*')
    .of('premium-content')
    .when(w => w.attr('tier', 'in', ['pro', 'enterprise']))
  )
  .build()
const p = policy('permissive')
  .algorithm('allow-overrides')
  .rule('deny-default', r => r.deny().on('*').of('*'))
  .rule('vip-access', r => r
    .allow()
    .on('*')
    .of('premium-content')
    .when(w => w.attr('tier', 'in', ['pro', 'enterprise']))
  )
  .build()

Evaluation logic:

  1. Find all matching rules.
  2. If any has effect: "allow", the policy result is allow.
  3. Else if any has effect: "deny", the policy result is deny.
  4. Else fall back to defaultEffect.

Use this when you want a deny-by-default policy where specific allow rules grant access. This is natural for RBAC -- if any role grants the permission, you are in.

first-match

The first matching rule determines the result. Order matters.

const p = policy('firewall')
  .algorithm('first-match')
  .rule('block-bad-ip', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w.env('ip', 'in', ['10.0.0.99', '10.0.0.100']))
  )
  .rule('allow-internal', r => r
    .allow()
    .on('*')
    .of('*')
    .when(w => w.env('ip', 'starts_with', '10.'))
  )
  .rule('deny-external', r => r.deny().on('*').of('*'))
  .build()
const p = policy('firewall')
  .algorithm('first-match')
  .rule('block-bad-ip', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w.env('ip', 'in', ['10.0.0.99', '10.0.0.100']))
  )
  .rule('allow-internal', r => r
    .allow()
    .on('*')
    .of('*')
    .when(w => w.env('ip', 'starts_with', '10.'))
  )
  .rule('deny-external', r => r.deny().on('*').of('*'))
  .build()

Evaluation logic:

  1. Walk rules in order.
  2. Collect all matching rules.
  3. The first matching rule's effect is the policy result.
  4. If none match, fall back to defaultEffect.

Use this for firewall-style ordered rule lists where you want explicit control over which rules take precedence based on their position.

highest-priority

The matching rule with the highest priority number wins.

const p = policy('priority')
  .algorithm('highest-priority')
  .rule('normal-allow', r => r
    .allow()
    .on('read')
    .of('post')
    .priority(10)
  )
  .rule('elevated-deny', r => r
    .deny()
    .on('read')
    .of('post')
    .when(w => w.resourceAttr('classification', 'eq', 'top-secret'))
    .priority(50)
  )
  .rule('emergency-override', r => r
    .allow()
    .on('*')
    .of('*')
    .when(w => w.role('super-admin'))
    .priority(100)
  )
  .build()
const p = policy('priority')
  .algorithm('highest-priority')
  .rule('normal-allow', r => r
    .allow()
    .on('read')
    .of('post')
    .priority(10)
  )
  .rule('elevated-deny', r => r
    .deny()
    .on('read')
    .of('post')
    .when(w => w.resourceAttr('classification', 'eq', 'top-secret'))
    .priority(50)
  )
  .rule('emergency-override', r => r
    .allow()
    .on('*')
    .of('*')
    .when(w => w.role('super-admin'))
    .priority(100)
  )
  .build()

Evaluation logic:

  1. Find all matching rules.
  2. Sort by priority descending.
  3. The highest-priority rule's effect is the policy result.
  4. If tied, the first encountered among tied rules wins.
  5. If none match, fall back to defaultEffect.

Use this when rules have clear importance tiers and you do not want to worry about ordering within the policy definition.

Complete Example: Layered Access Control

Here is a real-world example combining RBAC roles with ABAC policies:

import { createAccessConfig } from '@gentleduck/iam'
 
const access = createAccessConfig({
  actions: ['create', 'read', 'update', 'delete', 'publish'] as const,
  resources: ['post', 'comment', 'user'] as const,
  scopes: ['org-alpha', 'org-beta'] as const,
})
 
// --- RBAC: who can do what ---
 
const viewer = access.defineRole('viewer')
  .name('Viewer')
  .grantRead('post', 'comment')
  .build()
 
const editor = access.defineRole('editor')
  .name('Editor')
  .inherits('viewer')
  .grantCRUD('post')
  .grant('publish', 'post')
  .grantCRUD('comment')
  .build()
 
// --- ABAC: additional restrictions ---
 
const businessHours = access.policy('business-hours')
  .name('Business Hours Only')
  .desc('Deny write operations outside business hours')
  .target({ actions: ['create', 'update', 'delete', 'publish'] })
  .algorithm('first-match')
  .rule('deny-off-hours', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w
      .or(w => w
        .env('hour', 'lt', 9)
        .env('hour', 'gte', 17)
      )
    )
  )
  .rule('allow-in-hours', r => r
    .allow()
    .on('*')
    .of('*')
  )
  .build()
 
const contentSafety = access.policy('content-safety')
  .name('Content Safety')
  .algorithm('deny-overrides')
  .rule('owner-delete-only', r => r
    .deny()
    .on('delete')
    .of('post')
    .when(w => w
      .not(w => w
        .or(w => w
          .isOwner()
          .role('admin')
        )
      )
    )
  )
  .rule('no-banned-users', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w.attr('status', 'eq', 'banned'))
  )
  .build()
 
// --- Wire it up ---
 
const engine = access.createEngine({
  adapter: myAdapter,
  defaultEffect: 'deny',
})
 
// Save roles and policies
for (const role of [viewer, editor]) {
  await engine.admin.saveRole(role)
}
for (const pol of [businessHours, contentSafety]) {
  await engine.admin.savePolicy(pol)
}
 
// Check access
const allowed = await engine.can(
  'user-1',
  'update',
  { type: 'post', id: 'post-42', attributes: { ownerId: 'user-1' } },
  { hour: 14 },  // 2 PM -- within business hours
)
// true: editor role allows update, business hours policy allows, content safety allows
import { createAccessConfig } from '@gentleduck/iam'
 
const access = createAccessConfig({
  actions: ['create', 'read', 'update', 'delete', 'publish'] as const,
  resources: ['post', 'comment', 'user'] as const,
  scopes: ['org-alpha', 'org-beta'] as const,
})
 
// --- RBAC: who can do what ---
 
const viewer = access.defineRole('viewer')
  .name('Viewer')
  .grantRead('post', 'comment')
  .build()
 
const editor = access.defineRole('editor')
  .name('Editor')
  .inherits('viewer')
  .grantCRUD('post')
  .grant('publish', 'post')
  .grantCRUD('comment')
  .build()
 
// --- ABAC: additional restrictions ---
 
const businessHours = access.policy('business-hours')
  .name('Business Hours Only')
  .desc('Deny write operations outside business hours')
  .target({ actions: ['create', 'update', 'delete', 'publish'] })
  .algorithm('first-match')
  .rule('deny-off-hours', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w
      .or(w => w
        .env('hour', 'lt', 9)
        .env('hour', 'gte', 17)
      )
    )
  )
  .rule('allow-in-hours', r => r
    .allow()
    .on('*')
    .of('*')
  )
  .build()
 
const contentSafety = access.policy('content-safety')
  .name('Content Safety')
  .algorithm('deny-overrides')
  .rule('owner-delete-only', r => r
    .deny()
    .on('delete')
    .of('post')
    .when(w => w
      .not(w => w
        .or(w => w
          .isOwner()
          .role('admin')
        )
      )
    )
  )
  .rule('no-banned-users', r => r
    .deny()
    .on('*')
    .of('*')
    .when(w => w.attr('status', 'eq', 'banned'))
  )
  .build()
 
// --- Wire it up ---
 
const engine = access.createEngine({
  adapter: myAdapter,
  defaultEffect: 'deny',
})
 
// Save roles and policies
for (const role of [viewer, editor]) {
  await engine.admin.saveRole(role)
}
for (const pol of [businessHours, contentSafety]) {
  await engine.admin.savePolicy(pol)
}
 
// Check access
const allowed = await engine.can(
  'user-1',
  'update',
  { type: 'post', id: 'post-42', attributes: { ownerId: 'user-1' } },
  { hour: 14 },  // 2 PM -- within business hours
)
// true: editor role allows update, business hours policy allows, content safety allows

The evaluation flow for this request:

  1. RBAC policy (allow-overrides): editor role grants update on post -> allow.
  2. business-hours (first-match): hour is 14, so deny-off-hours condition (hour < 9 OR hour >= 17) is false -- rule does not match. Next rule allow-in-hours has no conditions, so it matches -> allow.
  3. content-safety (deny-overrides): user is not banned (status is not 'banned'), and the owner-delete-only rule targets delete but the request is update -- no deny rules match, no allow rules to fire -> falls through to defaultEffect.
  4. Cross-policy AND: RBAC allows, business-hours allows, content-safety uses default -> all must allow -> ALLOWED.

Important: when a policy has only deny rules and none match, the policy falls through to defaultEffect (typically "deny"). To avoid an accidental deny, either add an explicit allow rule (as in the business-hours policy above) or use first-match / allow-overrides with a catch-all allow as the last rule.