Skip to main content

Conditions

The When builder - every method, every operator with operand types and missing/NaN/empty semantics, field resolution, and regex safety limits

Conditions are the attribute checks inside a rule. The When builder produces AccessControl.ICondition leaves (field, operator, value) and nested AccessControl.IConditionGroup trees; the engine evaluates them against the request at decision time. This page is the operator reference and covers what happens with missing, wrongly typed, and empty values.

The evaluator distinguishes two failures. A field the request does not carry is a match failure - the operator answers false (or true, for the four negated operators). An operand the operator cannot compare against is a refusal: evalCondition throws a tagged error, the engine reports it through hooks.onPolicyError, and the policy becomes Indeterminate. The split exists because false is fail-closed only on an allow rule; on a deny rule it retires the deny, and inside a none group the negation turns it into a grant.

Indeterminate is a vote, never a skip. A policy that carries any deny rule votes deny; an allow-only policy votes defaultEffect. Dropping the policy instead is what would turn a throw into an allow - padding a User-Agent past 2048 characters is enough to make a matches rule throw, and no validator sees request attributes. The one exception is the generated __rbac__ policy when it has no deny rule: its rules are independent grants from separate roles, so one rotten permission abstains rather than poisoning the others.

The When builder

Every method appends one leaf or group and returns this. Top-level conditions combine with AND when the rule uses when(); see nesting for OR / NOT and whenAny().

.when((w) => w
  .attr('department', 'eq', 'engineering')
  .attr('level', 'gte', 5)
  .resourceAttr('classification', 'neq', 'top-secret')
)
// All three must hold

Raw check

check(field, operator, value?) is the general form. field is any resolvable dot-path; value may be omitted for unary operators:

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

Shorthand operator methods

MethodEmitsValue type
eq(field, value)eqfield's type or a $-path
neq(field, value)neqsame
in(field, values)inarray
contains(field, value)containsstring
exists(field)existsnone
gt / gte / lt / lte(field, value)numeric comparenumber
matches(field, regex)matchesstring pattern

There is no shorthand for nin, not_contains, starts_with, ends_with, not_exists, subset_of, superset_of, before, or after - use check().

.when((w) => w
  .eq('subject.id', 'user-1')
  .neq('resource.attributes.status', 'archived')
  .gt('subject.attributes.age', 18)
  .in('subject.attributes.tier', ['pro', 'enterprise'])
  .contains('subject.roles', 'admin')
  .exists('resource.attributes.ownerId')
  .matches('resource.attributes.email', '^.*@company\\.com$')
)

Semantic shortcuts

MethodEmits
role(roleId)subject.roles contains roleId
roles(...roleIds)subject.roles in [roleIds] (any overlap)
scope(id)scope eq id
scopes(...ids)scope in [ids]
isOwner(ownerField = 'resource.attributes.ownerId')ownerField eq '$subject.id'
resourceType(...types)resource.type in [types]
attr(key, op, value?)subject.attributes.<key> op value
resourceAttr(key, op, value?)resource.attributes.<key> op value
env(key, op, value?)environment.<key> op value

attr, resourceAttr, and env accept nested keys ('profile.tier') against nested attribute bags (since 2.0.0). With a typed context they autocomplete and type-check the value; resourceAttr narrows to the resource selected by of(). See type-safe roles and typed context.

How one condition is evaluated

Each leaf goes through the same steps. Knowing them explains every edge case below.

Loading diagram...

All condition operators

AccessControl.Operator has nineteen members. "Field" is the resolved left-hand side; "operand" is the resolved right-hand side. A missing field resolves to null. An operand of the wrong type is refused before the operator runs, so no operator ever sees one.

OperatorOperand requiredSemanticsAbsent field
eqscalarf === v, strictfalse; true if v is the literal null
neqscalarf !== v, stricttrue; false if v is the literal null
gtnumbertypeof f === 'number' && f > vfalse
gtenumberf >= vfalse
ltnumberf < vfalse
ltenumberf <= vfalse
inarray of scalarsarray field: any element is in v; scalar field: v.includes(f)false; true if v contains null
ninarray of scalarsnegation of intrue unless v contains null
containsscalarArray.isArray(f) && f.includes(v) - array membership, never substring searchfalse
not_containsscalarf == null is true; a non-array f is false; else !f.includes(v)true
starts_withstringtypeof f === 'string' && f.startsWith(v)false
ends_withstringf.endsWith(v)false
matchesstring, not a $-referencecompiled regex .test(f)false
existsnone (valueless)f !== null && f !== undefinedfalse
not_existsnone (valueless)f === null || f === undefinedtrue
subset_ofarray of scalarsboth must be arrays; f.every((i) => v.includes(i))false
superset_ofarray of scalarsboth must be arrays; v.every((i) => f.includes(i))false
beforenumber or ISO-8601 stringtoEpoch(f) < toEpoch(v), both finitefalse
afternumber or ISO-8601 stringtoEpoch(f) > toEpoch(v), both finitefalse

The four bold trues are the whole risk surface for a missing attribute. allow if subject.attributes.tier neq 'banned' grants to a subject that has no tier at all. Pair a negated operator with a presence check:

.when((w) => w
  .exists('subject.attributes.tier')
  .check('subject.attributes.tier', 'neq', 'banned')
)

Notes per family:

  • eq/neq are bare ===/!==. The scalar operand requirement exists because a non-scalar operand meant reference equality against a value resolved out of the request - never the same object - so eq was permanently false and neq permanently true.
  • contains is array membership. A groups claim arriving as the CSV string 'not-admins-really' used to satisfy contains 'admins', which is the normal shape of a JWT claim. A present non-array field now fails both contains and not_contains, so the same type confusion cannot bypass one in each direction. An absent field is a different case: an empty list contains nothing, so not_contains holds.
  • in/nin/subset_of/superset_of require every operand element to be a scalar, not just the container, because the membership check is includes. Object elements compare by reference and never match.
  • before/after coerce with toEpoch: numbers pass through as epoch ms, strings go through Date.parse, anything else is NaN. Both sides are checked with Number.isFinite, so Infinity fails too. Pair them with the engine-injected $environment.now.

What a wrong operand does

OperandResult
The right typethe operator runs
The value key absent on a non-valueless operatorIamOperandTypeError - "requires a value and the key is absent"
A $-reference that resolved to nullIamOperandTypeError - "operand reference ... resolved to nothing"
Any other type mismatch (in with a non-array, gt with a string, matches with 42)IamOperandTypeError naming the field and operator
A $-reference on matchesIamUserSourcedPatternError, refused before resolution

A literal value: null is an author explicitly testing for null and still works; only a $-reference resolving to nothing is refused. That distinction is what fixed the canonical multi-tenant guard: subject.attributes.tenant eq $resource.attributes.tenant compared null === null and allowed a request carrying neither attribute, through the fully validated authoring path, because the validator cannot type a $-reference.

Operand narrowing summary

  • Numeric operators (gt, gte, lt, lte) require typeof 'number' on both sides. Numeric strings do not coerce; a non-number operand throws rather than returning false.
  • String operators (starts_with, ends_with, matches) require strings on both sides.
  • Set operators (in, nin, contains, not_contains, subset_of, superset_of) only see IamPrimitives.Scalar elements (string | number | boolean | null); object elements never match.
  • Missing fields resolve to null. Use exists / not_exists to test presence rather than eq/neq against null.

Regex safety (matches)

matches is the only operator that compiles authored input, so it carries four guards. All four refuse rather than answer false, for the reason at the top of this page: a false inside a deny rule reads as "condition not met" and the deny stops firing.

GuardTriggerRefusal
No $-sourced patternsthe value begins with $IamUserSourcedPatternError, thrown before the reference is resolved
Pattern lengthpattern longer than MAX_REGEX_LENGTH (128)IamPatternRefusedError('too-long')
ReDoS shapedetectCatastrophicRegex refuses it, or it is not a valid regexIamPatternRefusedError('uncompilable')
Input lengththe resolved field string is longer than MAX_REGEX_INPUT_LENGTH (2048)IamRegexInputTooLargeError, carrying field and length

detectCatastrophicRegex is the single predicate both getCachedRegex and the validator run, so a pattern accepted at import time can never be refused at evaluation time or the reverse. It refuses nested quantifiers ((a+)+), alternation inside a quantified group ((a|aa)+), a backreference followed by a quantifier, a quantified group inside a lookaround, a {n,m} bound over 1000, more than four unbounded quantifiers, and unbounded quantifiers competing over overlapping atoms (^a+a+$, .*.*). Overlap is decided by probing each atom rather than by parsing character classes, so [a-z]+@[a-z]+\.[a-z]+ stays accepted. At build() time the same refusal surfaces as ERR_REGEX_CATASTROPHIC (see building policies).

Compiled patterns are cached in an LRU of REGEX_CACHE_MAX (256) entries, re-inserted on hit so eviction drops the least recently used. A refused pattern never reaches new RegExp and is never cached. The engine passes a per-instance cache so tenants cannot evict each other; the process-wide fallback is flushed with iamClearRegexCache(), or with iamFlushSharedCaches() from @gentleduck/iam/core/engine to clear the path cache alongside it. See caching.

Field resolution

Fields are dot-paths resolved against the IamRequest.IAccessRequest at evaluation time by resolve() in src/core/resolve/resolve.ts.

PathResolves to
actionThe request action (whole-path shorthand)
scopeThe request scope, or null when absent
subject.idSubject id
subject.rolesEffective roles array (inheritance and scoped-role enrichment applied)
subject.scopedRoles.<i>.attributes.<key>A per-assignment attribute. The bare subject.scopedRoles path resolves to null - it is an array of objects, which is outside AttributeValue
subject.attributes.<key>A subject attribute, any depth
resource.type / resource.idResource type and instance id
resource.attributes.<key>A resource attribute
environment.<key>ip, userAgent, timestamp, now, or any custom key

Rules:

  • Only subject, resource, and environment are valid roots, plus the two shorthands. Any other root resolves to null and the validator warns UNRESOLVABLE_FIELD.
  • __proto__, constructor, and prototype are refused at any segment (prototype-pollution guard) and the result is memoised as invalid, so the path is rejected once rather than walked per request.
  • The walk reads own properties only. A plain Reflect.get resolved every Object.prototype member - toString, valueOf, hasOwnProperty - to a function on any object, and an exists-gated allow fired against a subject with no attributes at all. exists asks whether the request carries the attribute, which is an own-property question.
  • The result is narrowed, not asserted. The final value must be a scalar, an array of scalars, or a flat object of scalars. A Date, a nested object, an array of objects, or a function from a live getSubjectAttributes all resolve to null. Adapters deserialise JSON and hand the result straight through, so those shapes genuinely arrive.
  • Traversal stops at the first non-object; undefined becomes null. A bad path never throws.
  • There is no distinction between "unknown path", "key absent", "key present with undefined", and "key present with null" - all four are null.
  • Split paths are memoised in a 10,000-entry FIFO cache (PATH_CACHE_MAX), per engine instance when supplied.

API reference

class When<TAction, TResource, TRole, TScope, TContext extends object = DotPath.IDefaultContext, TActiveResource extends string = string> {
  check<P extends DotPath.FlexibleDotPaths<TContext>>(field: P, op: AccessControl.Operator, value?: DotPath.FieldValue<TContext, P> | DotPath.FlexibleDollarPaths<TContext>): this
  eq<P>(field: P, value: DotPath.FieldValue<TContext, P> | DotPath.FlexibleDollarPaths<TContext>): this
  neq<P>(field: P, value: ...): this
  in<P>(field: P, values: Array<DotPath.FieldValue<TContext, P> | DotPath.FlexibleDollarPaths<TContext>>): this
  contains<P>(field: P, value: string): this
  exists<P>(field: P): this
  gt<P>(field: P, value: number): this
  gte<P>(field: P, value: number): this
  lt<P>(field: P, value: number): this
  lte<P>(field: P, value: number): this
  matches<P>(field: P, regex: string): this
  role(roleId: TRole): this
  roles(...roleIds: TRole[]): this
  scope(id: TScope): this
  scopes(...ids: TScope[]): this
  isOwner(ownerField?: DotPath.FlexibleDotPaths<TContext>): this
  resourceType(...types: (TResource | '*')[]): this
  attr<K extends DotPath.SubjectAttrs<TContext> & string>(path: K, op: AccessControl.Operator, value?: ...): this
  resourceAttr<K extends DotPath.ResolvedResourceAttrPaths<TContext, TActiveResource> & string>(path: K, op: AccessControl.Operator, value?: ...): this
  env<K extends DotPath.EnvAttrs<TContext> & string>(path: K, op: AccessControl.Operator, value?: ...): this
  and(fn: (w: When) => When): this
  or(fn: (w: When) => When): this
  not(fn: (w: When) => When): this
  buildAll(): { readonly all: ReadonlyArray<AccessControl.ICondition | AccessControl.IConditionGroup> }
  buildAny(): { readonly any: ReadonlyArray<...> }
  buildNone(): { readonly none: ReadonlyArray<...> }
}

Leaf and group types:

interface AccessControl.ICondition {
  readonly field: string
  readonly operator: AccessControl.Operator
  readonly value?: IamPrimitives.AttributeValue
}
type AccessControl.IConditionGroup = IConditionAll | IConditionAny | IConditionNone
// { all: [...] } | { any: [...] } | { none: [...] }

type IamPrimitives.Scalar = string | number | boolean | null
type IamPrimitives.AttributeValue = Scalar | Scalar[] | Record<string, Scalar>

Runtime helpers for tooling and tests, exported from @gentleduck/iam and @gentleduck/iam/core under the iam / IAM_ prefix: iamEvalCondition, iamEvalConditionGroup, iamEvaluateOperator, iamMatchesUnconditionally, iamResolveConditionValue, iamResolveValue, iamIsCondition, iamIsUserSourcedValue, iamGetCachedRegex, iamDetectCatastrophicRegex, iamClearRegexCache, and the limits IAM_MAX_CONDITION_DEPTH, IAM_MAX_REGEX_LENGTH, IAM_MAX_REGEX_INPUT_LENGTH, IAM_MAX_BOUNDED_QUANTIFIER, IAM_MAX_UNBOUNDED_QUANTIFIERS, IAM_REGEX_CACHE_MAX. Every condition error class is exported unprefixed: IamConditionGroupError, IamOperandTypeError, IamPatternRefusedError, IamRegexInputTooLargeError, IamUserSourcedPatternError - route them through onPolicyError rather than string-matching err.name.

The operator table ops and the process-wide regexCache are deliberately not exported. Neither is frozen at runtime, so withholding them from the barrel is the only thing keeping them internal: ops.eq = () => false would retire every eq deny rule in both evaluation modes. The engine-side contract is on evaluation pipeline.

Gotchas

  • contains() (the shorthand) only accepts a string value; use check(field, 'contains', 42) for numbers.
  • in with an array field is an overlap test, not equality: subject.roles in ['admin'] is true for ['viewer', 'admin'].
  • eq takes a scalar operand. An array or object operand is refused as IamOperandTypeError; for set equality use subset_of plus superset_of.
  • matches patterns are strings, so escape backslashes twice in TypeScript source ('^.*@company\\.com$').

See also