Skip to main content

rules

Rule builder API — effect, actions, resources, priority, scopes, and metadata.

Rule structure

A rule has four core pieces:

  • Effectallow or deny
  • Match set — actions and resources the rule applies to
  • Priority — used by the highest-priority combining algorithm
  • Conditions — contextual checks via the When builder (see conditions)

Builder methods

MethodDescription
allow()Set effect to allow (default)
deny()Set 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 wins 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

Effect

Every rule is either an allow or a deny:

defineRule('owner-allow').allow().on('update').of('post').when((w) => w.isOwner()).build()
 
defineRule('banned-deny').deny().on('*').of('*').when((w) => w.attr('status', 'eq', 'banned')).build()
defineRule('owner-allow').allow().on('update').of('post').when((w) => w.isOwner()).build()
 
defineRule('banned-deny').deny().on('*').of('*').when((w) => w.attr('status', 'eq', 'banned')).build()

allow() is the default effect — calling it explicitly is for clarity.


Actions and resources

Pass one or many. Wildcards ('*') match all:

.on('create', 'update', 'delete')   // CRUD subset
.on('*')                            // all actions
.of('post', 'comment')              // multiple resource types
.of('*')                            // all resources
.on('create', 'update', 'delete')   // CRUD subset
.on('*')                            // all actions
.of('post', 'comment')              // multiple resource types
.of('*')                            // all resources

See building policies for hierarchical resource matching (dashboard matches dashboard.users).


Priority

Used by the highest-priority combining algorithm. Default is 10:

defineRule('emergency-override')
  .allow()
  .on('*')
  .of('*')
  .when((w) => w.role('super-admin'))
  .priority(100) // beats everything
  .build()
defineRule('emergency-override')
  .allow()
  .on('*')
  .of('*')
  .when((w) => w.role('super-admin'))
  .priority(100) // beats everything
  .build()

Other algorithms (deny-overrides, allow-overrides, first-match) ignore priority.


Scopes (forScope)

Restrict a rule to specific scopes. Composes with when():

defineRule('org-admin-allow')
  .allow()
  .on('manage')
  .of('billing')
  .forScope('org-1', 'org-2')
  .when((w) => w.role('billing-admin'))
  .build()
defineRule('org-admin-allow')
  .allow()
  .on('manage')
  .of('billing')
  .forScope('org-1', 'org-2')
  .when((w) => w.role('billing-admin'))
  .build()

This rule fires only when the request's scope is org-1 or org-2 AND the subject has the billing-admin role.


Metadata

Attach arbitrary metadata to rules. Useful for audit logging, debugging, or admin UIs:

defineRule('gdpr-consent-required')
  .allow()
  .on('read')
  .of('user-profile')
  .when((w) => w.attr('gdprConsent', 'eq', true))
  .meta({
    compliance: 'GDPR',
    reviewedBy: 'legal-team',
    addedAt: '2026-01-15',
  })
  .build()
defineRule('gdpr-consent-required')
  .allow()
  .on('read')
  .of('user-profile')
  .when((w) => w.attr('gdprConsent', 'eq', true))
  .meta({
    compliance: 'GDPR',
    reviewedBy: 'legal-team',
    addedAt: '2026-01-15',
  })
  .build()

Metadata is preserved through serialization and surfaces in engine.explain() traces.


Empty rules vs unconditional rules

ShapeBehavior
r.allow().on('read').of('post') (no when)Always matches — fast path in the engine
r.allow().on('read').of('post').when((w) => w) (empty when)Same — vacuously true
r.allow().on('read').of('post').when((w) => w.eq('x', 'y'))Conditional

The fast-path engine cache (evaluatePolicyFast) pre-computes results for unconditional rules at policy-load time. This is the main reason r.on('read').of('post') (no when) outperforms r.on('read').of('post').when((w) => w) even though they're semantically identical.