Rule matching
How the engine decides whether a rule fires - action and resource patterns, condition groups, field resolution, $-references, and every condition operator with its edge-case semantics.
A rule fires only when its action pattern, its resource pattern, and its condition group all accept the request. This page documents each gate exactly as src/core/resolve/resolve.ts, src/core/evaluate/evaluate.libs.ts and src/core/conditions/* implement it, including what happens when a field is missing, a value is NaN, or an array is empty.
The three gates
Every rule passes through the same three checks, in order, and the first failure skips the rule.
The action and resource gates are pure string matching (matchesAction, matchesResource, matchesResourceHierarchical). The condition gate is evalConditionGroup, which resolves fields against the request and applies an operator per leaf. A rule with no conditions key, or with conditions: {}, skips the third gate entirely and is treated as unconditional; that is also what lets the indexer put it on the precomputed fast path described in evaluation.
Action patterns
matchesAction(pattern, action) accepts exactly three shapes.
| Pattern | Matches | Does not match |
|---|---|---|
* | any action | - |
read | read only (exact string equality) | read:all, reader |
admin:* | admin: followed by anything, for example admin:users | administer (no separator), admin |
A rule lists actions: string[]; the rule matches when any entry matches (ruleApplies uses some). The prefix wildcard is separator-bound: the test 'admin:*' does not match an action that merely starts with the same letters without the separator in compiled.combine-invariance.test.ts pins that.
Resource patterns
Resources have two matchers. matchesResource is used when neither the request's resource type nor the pattern contains a dot; matchesResourceHierarchical is used when either does.
| Pattern | Flat matcher (matchesResource) | Hierarchical matcher (matchesResourceHierarchical) |
|---|---|---|
* | any resource | any resource |
dashboard | dashboard only | dashboard only |
dashboard.* | dashboard. followed by anything | dashboard. followed by anything |
document:* | document: followed by anything | not supported (exact match only) |
The consequence that trips people up: a bare pattern is always an exact match. dashboard does not match dashboard.users; you must write dashboard.*. Two tests state this directly: bare "dashboard" rule does NOT match dashboard.users (require dashboard.*) and policy with bare target "dashboard" does NOT apply to "dashboard.users" in evaluate.test.ts. Like actions, .* is separator-bound: org.* does not match org-1 ('org.*' resource wildcard does not match a hyphen-joined lookalike).
dashboard rules to cover every sub-resource, list both dashboard and dashboard.* in the rule, or use dashboard.* alone when the root itself never needs the rule.Policy targets use the same matchers (policyTargetsActionResource), so the rule above applies to targets.actions and targets.resources too. See targets for the target side.
Condition groups
A condition group is one of three shapes, and groups nest.
type IConditionGroup =
| { all: (ICondition | IConditionGroup)[] } // every entry true
| { any: (ICondition | IConditionGroup)[] } // at least one entry true
| { none: (ICondition | IConditionGroup)[] } // no entry true
evalConditionGroup(req, group, depth = 0, caches?) evaluates:
allwithevery,anywithsome,nonewith!some.- An empty group (
{}, no key) returnstrue. - Nesting deeper than
MAX_CONDITION_DEPTH(10) returnsfalsefor the whole subtree. The cap is fail-closed on purpose: a runaway nested group cannot become an allow. - A leaf is anything with a
fieldkey (isCondition).
Field resolution
Every leaf field is a dot path. resolve(request, path, caches?) walks it against the request.
Rules of the walk:
- Allowed roots are
subject,resourceandenvironment(ALLOWED_ROOTS).actionandscopeare shorthands that return the request's action and scope; a missing scope resolves tonull. - Any other root resolves to
null. - A missing segment anywhere resolves to
null. There is no distinction between "key absent" and "value null". - Own properties only. The walk is
Object.hasOwn(node, seg) ? Reflect.get(node, seg) : undefined.Reflect.getalone resolves through the prototype chain, sotoString,valueOf,hasOwnPropertyand every otherObject.prototypemember resolved to a function on any object - and anexists-gated allow fired against a subject with no attributes at all.existsasks whether the request carries the attribute, which is an own-property question. A subject attribute literally namedtoStringstill resolves; the rule is about ownership, not about the name. - The resolved value is narrowed, not asserted.
isAttributeValueaccepts scalars, arrays of scalars, and plain (or null-prototype) objects whose values are all scalars. ADate, aMap, a class instance, a function, a doubly-nested object or a mixed array all resolve tonull. Adapters deserialize JSON and hand the result straight through, so these genuinely arrive; asserting the type left each operator's owntypeofguard as the only thing between a non-conforming value and a wrong comparison, and afalsefrom a deny rule's condition is a silent grant. The walk still descends through a nested object even though the object itself is not a value, sosubject.attributes.nested.deepresolves. - Segments named
__proto__,constructororprototypeare blocked (BLOCKED_SEGMENTS) and resolve tonull, which closes the prototype-pollution route through attacker-controlled attribute keys.BLOCKED_SEGMENTSis exported for one consumer:isResolvablePathin the validator, which must refuse what the resolver refuses. It shared onlyALLOWED_ROOTS, sosubject.__proto__.xpassed validation and then resolved tonull- the inert condition the validator exists to catch, and on adenyrule a rule that can never fire. - Parsed paths are cached in a FIFO map capped at
PATH_CACHE_MAX(10,000) entries, and rejections are negative-cached (nullunder the path) so a blocked path is refused once at parse time rather than walked per request. Pass a per-Engine map to keep tenants from evicting each other;clearPathCache()empties only the process-wide map. The map itself andALLOWED_ROOTSare deliberately not re-exported - handing out the mutableMaplets a consumer seat a bogus segment list under a path a deny rule resolves, andALLOWED_ROOTSis typedReadonlySetbut erases to a liveSet, so.delete('subject')would make everysubject.*path unresolvable and the cache would memoize that.
Typical fields: subject.id, subject.roles, subject.scopedRoles, subject.attributes.department, resource.attributes.ownerId, environment.now, environment.ip.
Value references
A leaf value that is a string beginning with $ is resolved against the request instead of being used literally (resolveValue). $subject.id becomes the requesting subject's id at evaluation time, which is how ownership rules are written without a per-user policy.
import { definePolicy } from '@gentleduck/iam/core'
export const documents = definePolicy('documents')
.name('Documents')
.algorithm('deny-overrides')
.rule('owner-can-edit', (r) =>
r
.allow()
.on('update', 'delete')
.of('document')
.when((w) => w.check('resource.attributes.ownerId', 'eq', '$subject.id')),
)
.build()
This ownership check is common enough to have a shorthand. w.isOwner() expands to exactly the condition above, and takes an optional field path when the owner is stored somewhere other than resource.attributes.ownerId.
Two guards apply. A $-reference that does not resolve yields null, so eq against a missing field is simply false unless the other side is also null. And the matches operator refuses a $-sourced pattern outright (isUserSourcedValue): a request must never be able to supply the regex.
Operator reference
The value passed to an operator is cond.value ?? null after $-resolution; the field is the resolved value or null. typeof checks are strict, so there is no string-to-number coercion anywhere.
| Operator | Field type | Value type | True when | Missing field (null) | Other edge cases |
|---|---|---|---|---|---|
eq | any | any | field === value | true only if value is null | no deep equality: arrays and objects compare by reference, so effectively never equal |
neq | any | any | field !== value | true unless value is null | same reference semantics as eq |
gt gte lt lte | number | number | numeric comparison | false | either side not a number: false; NaN on either side: false |
in | scalar or scalar[] | array | scalar field is in list; array field shares at least one scalar with list | [..., null] matches, else false | value not an array: false; object field: false |
nin | scalar or scalar[] | array | negation of in | true unless list contains null | value not an array: true; object field: true |
contains | array or string | scalar / string | array includes scalar value, or string includes substring | false | array field with non-scalar value: false; number field: false |
not_contains | array or string | scalar / string | negation of contains | true | any shape contains cannot handle returns true |
starts_with | string | string | field.startsWith(value) | false | non-string on either side: false; empty value: true for any string field |
ends_with | string | string | field.endsWith(value) | false | same as starts_with |
matches | string | string | regex test | false | see the ReDoS section below |
exists | any | ignored | field is not null and not undefined | false | 0, '', false and [] all exist |
not_exists | any | ignored | field is null or undefined | true | |
subset_of | array | array | every field item is in value | false | [] is a subset of anything; non-array on either side: false |
superset_of | array | array | every value item is in field | false | any array is a superset of []; non-array on either side: false |
after | number or ISO string | number or string | toEpoch(field) > toEpoch(value) | false | unparsable string, NaN, boolean or array on either side: false |
before | number or ISO string | number or string | toEpoch(field) < toEpoch(value) | false | same as after |
toEpoch passes numbers through unchanged and runs Date.parse on strings, so environment.now (a millisecond epoch injected by the engine) compares correctly with an ISO string value. Both sides must be finite; an Infinity or NaN epoch is a false, not an error.
nin and not_contains return true whenever the shapes do not line up (non-array value, object field, missing field). Never gate an allow rule on a negative operator alone; pair it with exists in the same all group so a missing attribute cannot satisfy the rule.matches and ReDoS protection
matches is the only operator that can throw, and it is also the only one with a pattern budget.
- Field or value not a string:
false. - Pattern longer than
MAX_REGEX_LENGTH(128):false. - Pattern that fails to compile, or that contains a nested quantifier such as
(a+)+(NESTED_QUANTIFIER_RE):false. Invalid patterns are cached asnullso they are not recompiled per request. - Input longer than
MAX_REGEX_INPUT_LENGTH(2048): throwsRegexInputTooLargeError(name: 'RegexInputTooLargeError',tag: 'duck-iam/regex-input-too-large',field,length).evalConditionrethrows it with the offending field name; the evaluator catches it per policy, skips that policy, and reports throughonPolicyError. Throwing rather than returningfalseis deliberate: adenyrule guarded bymatchesmust not flip to allow on oversized input. - A
$-sourced pattern is refused before anything else runs.
Compiled patterns live in an LRU of REGEX_CACHE_MAX (256) entries. Each engine instance owns its own regex cache (iamCreateEvalCaches()), and the process-wide one is cleared by iamFlushSharedCaches() from @gentleduck/iam/core/engine. (The underlying clearRegexCache is internal; core/conditions does not re-export it.)
When a rule does not fire
Work through the gates in order when a rule you expect to match is silent:
- Action: is the request action exactly one of
rule.actions, or covered by aprefix:*entry? - Resource: is
request.resource.typeexactly one ofrule.resources, or covered byprefix.*(orprefix:*when no dots are involved)? Remember a bare pattern is exact. - Policy target: did the policy's
targetsaccept the action, resource and subject roles at all? A policy whose targets do not match is not applicable and its rules are never consulted. - Conditions: run
engine.explain()indevelopmentmode. The trace lists every policy and every rule with the reason it was or was not applied, including operator results.
Gotchas
eqon a missing field against anullvalue is true. If you mean "attribute present and equal", addexists.inwith a non-array value is always false, even when the field equals the value. Useeqfor a single value.- The hierarchical matcher is chosen when the request's resource type contains a dot, not the id.
resource.idnever participates in matching; useresource.attributesconditions to constrain ids. environment.nowis injected only when the caller did not supply it, so a rule that readsenvironment.nowbehaves deterministically in tests when you pass a fixed value.
See also
- Evaluation - the pipeline that runs these gates for every applicable policy.
- Cross-policy semantics - how per-policy outcomes are combined.
- Rules and targets - authoring the patterns this page matches.
- Conditions - the builder API for condition groups.