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:
- 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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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:
| Operator | Description | Example |
|---|---|---|
eq | Strict equality (===) | w.eq('subject.id', 'user-1') |
neq | Strict inequality (!==) | w.neq('resource.attributes.status', 'deleted') |
gt | Greater than (numbers only) | w.gt('subject.attributes.age', 18) |
gte | Greater than or equal (numbers only) | w.gte('resource.attributes.priority', 5) |
lt | Less than (numbers only) | w.lt('resource.attributes.price', 1000) |
lte | Less than or equal (numbers only) | w.lte('subject.attributes.riskScore', 3) |
in | Value is in the given array. If field is an array, checks if any element overlaps. | w.in('subject.attributes.tier', ['pro', 'enterprise']) |
nin | Value is NOT in the given array. | w.check('subject.attributes.status', 'nin', ['banned', 'suspended']) |
contains | Array contains the value, or string contains the substring. | w.contains('subject.roles', 'admin') |
not_contains | Array does NOT contain the value, or string does NOT contain the substring. | w.check('subject.attributes.tags', 'not_contains', 'blocked') |
starts_with | String starts with the given prefix. | w.check('resource.attributes.path', 'starts_with', '/admin') |
ends_with | String ends with the given suffix. | w.check('resource.attributes.email', 'ends_with', '@company.com') |
matches | String 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-]+$') |
exists | Field is not null and not undefined. The value parameter is ignored. | w.exists('resource.attributes.publishedAt') |
not_exists | Field is null or undefined. The value parameter is ignored. | w.check('resource.attributes.deletedAt', 'not_exists') |
subset_of | Every 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_of | Every 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 returnsfalse. - String operators (
starts_with,ends_with,matches): both must be strings. Non-string values returnfalse. inwith arrays: if the field is an array (likesubject.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.containswith strings vs arrays: if the field is an array, usesArray.includes(). If the field is a string, usesString.includes(). Other types returnfalse.subset_of/superset_of: both field and value must be arrays. Non-array values returnfalse.- Missing fields: if a field path resolves to
nullorundefined, comparison operators (eq,gt, etc.) compare againstnull. Useexists/not_existsto check presence.
Field Resolution
Conditions reference fields using dot-notation paths. The engine resolves these paths
against the AccessRequest at evaluation time.
Supported paths
| Path | Resolves to |
|---|---|
subject.id | The subject's ID string. |
subject.roles | The subject's roles array. |
subject.attributes.<key> | A subject attribute. Nest as deep as needed. |
resource.type | The resource type string. |
resource.id | The resource instance ID. |
resource.attributes.<key> | A resource attribute. |
environment.<key> | An environment value (ip, userAgent, timestamp, or custom). |
action | Shorthand for the action string on the request. |
scope | Shorthand 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.
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 ownerAND -- 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 >= 5NOT -- 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 suspendedComposing 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:
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 ownerconst 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 ownerPolicy 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.
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
| Field | Description |
|---|---|
actions | Only evaluate this policy if the request action matches one of these. |
resources | Only evaluate this policy if the request resource matches one of these. |
roles | Only 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
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:
- Find all matching rules.
- If any has
effect: "deny", the policy result is deny. - Else if any has
effect: "allow", the policy result is allow. - 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:
- Find all matching rules.
- If any has
effect: "allow", the policy result is allow. - Else if any has
effect: "deny", the policy result is deny. - 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:
- Walk rules in order.
- Collect all matching rules.
- The first matching rule's effect is the policy result.
- 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:
- Find all matching rules.
- Sort by
prioritydescending. - The highest-priority rule's effect is the policy result.
- If tied, the first encountered among tied rules wins.
- 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 allowsimport { 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 allowsThe evaluation flow for this request:
- RBAC policy (
allow-overrides): editor role grantsupdateonpost-> allow. - business-hours (
first-match): hour is 14, sodeny-off-hourscondition (hour < 9 OR hour >= 17) is false -- rule does not match. Next ruleallow-in-hourshas no conditions, so it matches -> allow. - content-safety (
deny-overrides): user is not banned (statusis not'banned'), and theowner-delete-onlyrule targetsdeletebut the request isupdate-- no deny rules match, no allow rules to fire -> falls through todefaultEffect. - 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.