Chapter 3: policies, rules, and conditions
Add attribute-based rules on top of the roles - ownership, draft visibility, archive locks - and learn how policies combine
Roles answer "is Bob an editor". They cannot answer "may Bob update this document". That needs attributes: who owns the document, what state it is in, when the request arrived. This chapter adds two ABAC policies to DocDuck and shows how they combine with the roles from chapter 2.
Learning goals
- Write a policy with
definePolicy, rules with.rule(), and conditions with theWhenbuilder. - Read a condition as three parts: field, operator, value.
- Know all nineteen operators and how each behaves on missing or wrong-typed data.
- Use
$-variables to compare one part of the request against another. - Pick a combining algorithm for a policy, and know how policies combine with each other.
- Avoid the deny-only policy trap that silently denies everything it targets.
Where policies sit
Each policy decides on its own, using its own combining algorithm over its own rules. The engine then merges those verdicts with policyCombine, which defaults to 'and': every applicable policy must allow. A policy that has nothing to say is marked not applicable and skipped rather than counted as a deny.
Writing the policies
Ownership: only the author, or an admin, may write
Create src/policies.ts:
import { definePolicy } from '@gentleduck/iam'
export const ownershipPolicy = definePolicy('document-ownership')
.name('Document ownership')
.desc('Writes to a document are limited to its author, unless the subject is an admin')
.version(1)
.algorithm('deny-overrides')
.target({ actions: ['update', 'delete', 'share'], resources: ['document'] })
.rule('deny-non-owner-write', (r) =>
r
.deny()
.desc('Only the author may write, admins excepted')
.priority(100)
.on('update', 'delete', 'share')
.of('document')
.when((w) =>
w.resourceAttr('ownerId', 'neq', '$subject.id').not((n) => n.role('admin')),
),
)
.rule('allow-owner-write', (r) =>
r
.allow()
.desc('Nothing above objected, so this policy consents')
.priority(1)
.on('update', 'delete', 'share')
.of('document'),
)
.build()
The deny rule fires when the document's ownerId differs from the subject's ID and
the subject is not an admin. The second rule is the policy's consent vote - keep reading,
it is not optional.
Lifecycle: drafts are private, archives are frozen
export const lifecyclePolicy = definePolicy('document-lifecycle')
.name('Document lifecycle')
.desc('Drafts are visible only to their author; archived documents are read-only')
.version(1)
.algorithm('deny-overrides')
.target({ resources: ['document'] })
.rule('deny-foreign-drafts', (r) =>
r
.deny()
.desc('A draft is visible only to its author')
.priority(60)
.on('read')
.of('document')
.when((w) =>
w
.resourceAttr('status', 'eq', 'draft')
.resourceAttr('ownerId', 'neq', '$subject.id'),
),
)
.rule('deny-archived-writes', (r) =>
r
.deny()
.desc('Archived documents cannot be modified')
.priority(60)
.on('update', 'delete', 'share')
.of('document')
.when((w) => w.resourceAttr('status', 'eq', 'archived')),
)
.rule('allow-otherwise', (r) =>
r.allow().desc('No lifecycle objection').priority(1).on('*').of('document'),
)
.build()
export const policies = [ownershipPolicy, lifecyclePolicy]
Wire them into the adapter
import { IamEngine } from '@gentleduck/iam'
import { IamMemoryAdapter } from '@gentleduck/iam/adapters/memory'
import { policies } from './policies'
import { roles } from './roles'
export const adapter = new IamMemoryAdapter({
roles,
policies,
assignments: {
alice: ['viewer'],
bob: ['editor'],
carol: ['admin'],
},
})
export const engine = new IamEngine({ adapter, mode: 'development' })
mode: 'development' is what makes engine.explain() below available; it throws in
production mode.
Run the matrix
import { engine } from './access'
const bobDoc = { type: 'document', id: 'doc-1', attributes: { ownerId: 'bob', status: 'published' } }
const aliceDoc = { type: 'document', id: 'doc-2', attributes: { ownerId: 'alice', status: 'published' } }
const aliceDraft = { type: 'document', id: 'doc-3', attributes: { ownerId: 'alice', status: 'draft' } }
const archived = { type: 'document', id: 'doc-4', attributes: { ownerId: 'bob', status: 'archived' } }
async function main() {
console.log(await engine.can('bob', 'update', bobDoc)) // true
console.log(await engine.can('bob', 'update', aliceDoc)) // false - not the owner
console.log(await engine.can('carol', 'update', aliceDoc)) // true - admin exception
console.log(await engine.can('bob', 'read', aliceDoc)) // true - published
console.log(await engine.can('bob', 'read', aliceDraft)) // false - someone else's draft
console.log(await engine.can('alice', 'read', aliceDraft)) // true - her own draft
console.log(await engine.can('bob', 'update', archived)) // false - frozen
console.log(await engine.can('alice', 'update', aliceDoc)) // false - viewer has no update grant
}
void main()
What just happened
Take the denial - Bob updating Alice's document - and ask the engine to narrate it:
const trace = await engine.explain('bob', 'update', aliceDoc)
console.log(trace.summary)
DENIED: "bob" attempting update on document
Roles: [editor, viewer]
__rbac__ [allow-overrides]: Allowed by rule "__rbac__#5" (1/16 rules matched)
document-ownership [deny-overrides]: Denied by rule "deny-non-owner-write" (2/2 rules matched)
document-lifecycle [deny-overrides]: Allowed by rule "allow-otherwise" (1/3 rules matched)
Result: Denied by rule "deny-non-owner-write"
__rbac__allows: Bob'seditorrole grantsupdateondocument.document-ownershipis applicable - its targets coverupdateondocument. Both its rules match by shape; the deny rule's conditions hold (ownerIdisalice, notbob; Bob is not an admin), sodeny-overridesreturns the deny.document-lifecycleis applicable too, and itsallow-otherwiserule matches, so it consents.policyCombine: 'and'returns the first deny it meets. Overall: denied.
Remove allow-owner-write and Bob can no longer update his own document either. With only the deny rule left, the policy is still applicable for update on document - the rule's action and resource shape matches - but no rule matched, so deny-overrides falls through to defaultEffect: 'deny'. Under policyCombine: 'and' that is a deny vote, and the decision reads No matching rules. Defaulted to deny with policy: 'document-ownership' and no rule.
A policy that can deny must also be able to consent. Give every restriction policy a low-priority catch-all allow, or narrow its target so tightly that it is never applicable when its deny rules cannot fire.
A policy is skipped entirely - applicable: false - in exactly two cases: its targets do not match the request, or none of its rules covers this action and resource at all. Anything else counts as a vote.
How a condition works
interface ICondition {
readonly field: string
readonly operator: AccessControl.Operator
readonly value?: IamPrimitives.AttributeValue
}
Field resolution
| Path | Resolves to |
|---|---|
subject.id | The subject ID |
subject.roles | The effective role array |
subject.attributes.<name> | A subject attribute |
resource.type | The resource type string |
resource.id | The resource instance ID |
resource.attributes.<name> | A resource attribute |
environment.<name> | Anything you passed as the environment argument |
action | Whole-path shorthand for the action string |
scope | Whole-path shorthand for the scope string, null when unscoped |
subject, resource, or environment - or be exactly action or scope. Anything else resolves to null. The segments __proto__, constructor, and prototype are refused at parse time, so a condition cannot walk into the prototype chain. validatePolicy reports either shape as an UNRESOLVABLE_FIELD warning, which is worth failing your build on: a path that always resolves to null is a rule that can never fire, and on a deny rule that is a hole. Parsed paths are memoised, per engine when you thread a cache through, otherwise in a process-wide map.A missing field resolves to null, and null flows into the operator like any other value. That is why neq against an absent ownerId is true and the deny fires: omitting data cannot buy you access.
Every operator
Nineteen of them. Read the two right-hand columns together: the field is whatever the request happened to carry, so a wrong-typed field is an ordinary miss, while the value you wrote in the policy is screened before the operator runs and a wrong-typed one throws.
| Operator | Holds when | Field absent | Field wrong type |
|---|---|---|---|
eq | strict === | false | false |
neq | strict !== | true | true |
gt, gte, lt, lte | both operands are numbers and compare | false | false |
in | array field shares an element with the value, or the value array includes a scalar field | false | false |
nin | the negation of in | true | true |
contains | the field is an array that includes the value | false | false |
not_contains | the field is an array that does not include the value | true | false |
starts_with, ends_with | both are strings and the prefix or suffix matches | false | false |
matches | field string matches the regex in value | false | false |
exists | field is neither null nor undefined | false | true |
not_exists | field is null or undefined | true | false |
subset_of | both are arrays and every field element is in the value | false | false |
superset_of | both are arrays and every value element is in the field | false | false |
before, after | both coerce to a finite epoch and compare | false | false |
The numeric, string, and array operators refuse to coerce. gt on a string field is false, not a lexicographic comparison, so a stringly-typed attribute can never accidentally satisfy a threshold. contains is array membership and nothing else: a CSV string arriving where a list was expected fails contains and not_contains, so the same type confusion cannot bypass one in each direction.
The four bolded trues are the whole risk surface for a missing attribute. allow when subject.attributes.tier neq 'banned' grants to a subject with no tier at all. Pair a negated operator with an exists guard: w.exists('subject.attributes.tier').attr('tier', 'neq', 'banned').
in, nin, subset_of and superset_of require an array of scalars as the value; the comparison operators require a number; matches requires a literal string. Anything else - including a $-reference that resolved to nothing - throws IamOperandTypeError before the operator runs. That is deliberate: a false retires a deny rule, and inside a none group it turns into a grant, so anything the evaluator cannot answer throws and the caller decides the vote.(a+)+, (a|aa)+, a{1,2000}) throws IamPatternRefusedError; a field longer than 2048 characters throws IamRegexInputTooLargeError; a value that is a $-path throws IamUserSourcedPatternError, because a subject-supplied regex is never compiled. A throw makes the policy indeterminate, not absent: the engine fires onPolicyError and casts a deny if that policy carries any deny rule, otherwise its defaultEffect vote. Compiled patterns are cached in a 256-entry LRU.$-variables
A string value that starts with $ is resolved as a path against the same request, at evaluation time:
w.resourceAttr('ownerId', 'eq', '$subject.id') // owner is the caller
w.check('resource.attributes.teamId', 'eq', '$scope') // resource belongs to the active scope
w.check('resource.attributes.expiresAt', 'after', '$environment.now')
Anything without a leading $ is a literal. 'published' is the string; '$subject.id' is a lookup.
environment.now to the current epoch milliseconds, unless a beforeEvaluate hook already pinned one. That makes before/after comparisons against '$environment.now' work without the caller passing a clock, and lets tests pin time deterministically.The When builder
.when(fn) and .whenAny(fn) hand you a When instance. Every method returns this.
| Method | Emits |
|---|---|
.check(field, op, value?) | the raw condition; everything else is sugar over it |
.eq, .neq, .in, .contains, .exists, .gt, .gte, .lt, .lte, .matches | that operator on the given full path |
.attr(path, op, value?) | subject.attributes.<path> |
.resourceAttr(path, op, value?) | resource.attributes.<path> |
.env(path, op, value?) | environment.<path> |
.isOwner(field?) | <field> eq '$subject.id', defaulting to resource.attributes.ownerId |
.role(id) | subject.roles contains id |
.roles(...ids) | subject.roles in ids |
.scope(id) / .scopes(...ids) | scope eq id / scope in ids |
.resourceType(...types) | resource.type in types |
.and(fn) / .or(fn) / .not(fn) | a nested all / any / none group |
.buildAll() / .buildAny() / .buildNone() | the finished group, for standalone use |
The standalone when() factory builds groups outside a rule:
import { when } from '@gentleduck/iam'
const adminOrOwner = when().role('admin').isOwner().buildAny()
const notArchived = when().resourceAttr('status', 'eq', 'archived').buildNone()
Grouping conditions
// AND (the default for .when)
.when((w) => w.isOwner().resourceAttr('status', 'neq', 'archived'))
// OR
.whenAny((w) => w.role('admin').isOwner())
// NOT, nested inside an AND
.when((w) => w.isOwner().not((n) => n.role('suspended')))
// admin OR (editor AND owner)
.when((w) => w.or((o) => o.role('admin').and((a) => a.role('editor').isOwner())))
Groups nest up to MAX_CONDITION_DEPTH, which is 10; the comparison is >=, so a tree exactly ten groups deep evaluates and eleven throws IamConditionGroupError. It throws rather than answering false for the reason above - a false inside a none group is a grant. validateRoles and validatePolicy enforce the same bound at authoring time, so a policy that builds cannot hit it at runtime.
The RuleBuilder API
definePolicy('example').rule('my-rule', (r) =>
r
.deny() // or .allow(); allow is the default
.desc('why this rule exists')
.priority(100) // default 10
.on('update', 'delete') // default ['*']
.of('document') // default ['*']
.forScope('team-acme') // chapter 5
.when((w) => w.isOwner()) // all-of group
.meta({ owner: 'platform' }),
)
| Method | Default | Notes |
|---|---|---|
.allow() / .deny() | allow | The rule's effect. |
.desc(d) | - | Surfaced in explain traces. |
.priority(n) | 10 | Ranks matches under first-match and highest-priority. A non-finite priority ranks as 0. |
.on(...actions) | ['*'] | Replaces the list, does not append. |
.of(...resources) | ['*'] | Also narrows the type of .resourceAttr() when the config is typed. |
.forScope(...scopes) | none | Prepends scope eq s or scope in [...]. Passing only '*' is a no-op. |
.when(fn) / .whenAny(fn) | empty all group | all versus any semantics. The later call wins. |
.meta(m) | - | Never evaluated. |
.build() | - | Merges any forScope condition into the group, so call order does not matter. |
defineRule(id) builds a rule outside a policy; .addRule(rule) puts a prebuilt rule into one. Rules are plain data and can be shared across policies.
The PolicyBuilder API
| Method | Default | Notes |
|---|---|---|
.name(n) | the policy ID | Display name. |
.desc(d) | - | Documentation only. |
.version(v) | - | Your own change tracking; the engine ignores it. |
.algorithm(a) | 'deny-overrides' | How this policy's own rules combine. |
.target(t) | matches everything | actions, resources, roles - each optional, each an OR within itself, all ANDed together. |
.rule(id, fn) | - | Inline rule. |
.addRule(rule) | - | Prebuilt rule. |
.build() | - | Validates the whole policy and throws on error-level issues. |
PolicyBuilder.build() runs the full policy validator and throws a message naming the policy ID and every failing code, for example INVALID_OPERATOR or UNRESOLVABLE_FIELD. Warnings do not throw: BROAD_ALLOW fires when a rule allows '*' on '*' with no conditions, and limits are 1000 rules per policy, 100 actions and 100 resources per rule.targets.resources uses the plain matcher, not the dot-aware hierarchical one that rule resources use. A target of ['document'] will not cover document.draft.
Combining algorithms
| Algorithm | Use it for |
|---|---|
deny-overrides | Restriction policies. The default, and what both DocDuck policies use. |
allow-overrides | Permissive layers. The synthetic __rbac__ policy uses this - any role that grants the permission is enough. |
first-match | Ordered, firewall-style lists. Despite the name it ranks by priority first and only falls back to source order on ties. |
highest-priority | Emergency overrides layered over a stable rule set. |
When no rule matched, every algorithm returns defaultEffect with the reason No matching rules. Defaulted to deny.
Combining policies
policyCombine is engine-level and defaults to 'and'.
| Value | Behaviour |
|---|---|
'and' | Every applicable policy must allow. The first deny wins and short-circuits. |
'allow-overrides' | The first applicable allow wins; a deny only stands if nothing allowed. |
'first-applicable' | The first policy that actually matched a rule decides. Development mode only - the production compiled table cannot represent it, and the constructor throws if you pair them. |
Not-applicable policies are skipped under all three. When nothing was applicable at all, the reason reads No policy applicable. Defaulted to deny.
Try it
- Delete
allow-owner-writeand rerun the matrix. Every write, including Bob's own, becomesfalse. Put it back. - Add a suspension rule to
document-ownership: deny every write whensubject.attributes.statusis'suspended'. Seed the adapter withattributes: { bob: { status: 'suspended' } }and confirm Bob loses write access to his own document. - Add an expiry rule: deny
readwhenresource.attributes.expiresAtisbefore'$environment.now'. Pass an explicitenvironmentof{ now: Date.parse('2030-01-01') }and watch the same document flip. - Switch
document-lifecycletoalgorithm('highest-priority')and giveallow-otherwisea priority of100. The deny rules stop mattering - that is the algorithm doing exactly what it says.
See also
- Building policies - the builder reference
- Rules and targets
- Conditions - the full operator semantics table
- Dollar variables and nesting
- Combining algorithms and cross-policy combination
- Chapter 4: the engine in depth