Skip to main content

Utility helpers

Every exported matcher, resolver, condition primitive, cache, and key helper - signatures, exact semantics, and runnable examples

The engine is built from small pure functions, and most of them are exported. Reach for them when you need matching or resolution without a full engine call: a routing guard, a unit test that pins matching logic in isolation, middleware that resolves request attributes before evaluation, or a hand-rolled pipeline.

Import surface

Helper groupImport fromNaming
Matchers, resolve, path-cache controls@gentleduck/iam or @gentleduck/iam/coreunprefixed
Condition primitives@gentleduck/iam or @gentleduck/iam/coreiam / IAM_ prefixed
iamEscapeHtml@gentleduck/iam, @gentleduck/iam/core, or @gentleduck/iam/core/explainprefixed
IamLRUCache, key helpers@gentleduck/iam (root only not in /core)prefixed
Row parsers@gentleduck/iam/core/validate onlyunprefixed
import {
  matchesAction,
  matchesResource,
  matchesResourceHierarchical,
  matchesScope,
  resolve,
  iamEvaluateOperator,
  iamResolveConditionValue,
} from '@gentleduck/iam'

The root entry is export * from './core' plus five shared-module exports, so anything in /core is also at the root. The reverse is not true: IamLRUCache, iamLRUCache, iamBuildPermissionKey, iamParsePermissionKey, and iamSplitPermissionKey live in src/shared and are exported only from the root.

What is deliberately not exported

Four module globals stay internal, and the reason is the same in each case: they are mutable, process-wide, and shared by every engine in the process, so handing a consumer the reference is handing them the ability to retire a deny rule.

WithheldWhat a consumer could do with itUse instead
opsops.eq = () => false retires every eq deny rule, in both evaluation modesiamEvaluateOperator
regexCacheSeat a permissive RegExp under a pattern a deny rule relies oniamClearRegexCache()
pathCacheSeat a bogus segment list under a path a deny rule resolvesclearPathCache()
ALLOWED_ROOTSTyped ReadonlySet, erases to a live Set .delete('subject') makes every subject.* path unresolvable, and pathCache memoises that so it sticks

Neither is frozen at runtime, so withholding them from the barrel is the only thing keeping them internal. The raw evaluatePolicy / evaluatePolicyFast are withheld for a different reason: they carry no allowFailOpen gate. iamEvaluatePolicy and iamEvaluatePolicyFast are the gated public names.

Where they sit in evaluation

The matchers and the resolver are the two halves of one rule check; the condition primitives sit under them.

Loading diagram...

matchesScope is the one that is not part of rule matching: it gates whether an RBAC permission applies in a given tenant scope. The dot test in the middle is exactly what traceRule and the evaluator do — the pattern or the request resource type having a dot is enough to switch to the hierarchical matcher.

Pattern matchers

matchesAction

function matchesAction(pattern: string, action: string): boolean

'*' matches everything. A pattern ending in ':*' matches any action sharing the prefix including the colon. Everything else is an exact string comparison.

matchesAction('*', 'delete')            // true  - global wildcard
matchesAction('read', 'read')           // true  - exact
matchesAction('read', 'write')          // false
matchesAction('posts:*', 'posts:read')  // true  - prefix 'posts:'
matchesAction('posts:*', 'posts')       // false - the colon is part of the prefix
matchesAction('posts:*', 'users:read')  // false

matchesResource

function matchesResource(pattern: string, resourceType: string): boolean

'*' matches everything. A pattern ending in ':*' or '.*' matches any resource type sharing the prefix including the separator. The separator is taken from the pattern, so a dot pattern only matches dot-style resource types and a colon pattern only matches colon-style ones. Everything else is exact.

matchesResource('*', 'post')                 // true
matchesResource('post', 'post')              // true
matchesResource('post', 'comment')           // false
matchesResource('org:*', 'org:project')      // true
matchesResource('org:*', 'org')              // false - separator required
matchesResource('post.*', 'post.draft')      // true
matchesResource('org', 'org:project:doc')    // false - a bare pattern is literal
matchesResource('org:*', 'org.project')      // false - separators do not mix

matchesResourceHierarchical

function matchesResourceHierarchical(pattern: string, resourceType: string): boolean

The dot-only variant, used when either side contains a .. '*' is global, an exact match wins, and only an explicit '.*' suffix enables the recursive prefix match. Unlike matchesResource, a ':*' suffix means nothing here.

matchesResourceHierarchical('*', 'anything')                    // true
matchesResourceHierarchical('dashboard', 'dashboard')           // true
matchesResourceHierarchical('dashboard', 'dashboard.users')     // false
matchesResourceHierarchical('dashboard.*', 'dashboard.users')   // true
matchesResourceHierarchical('dashboard.*', 'dashboard.a.b')     // true - recursive
matchesResourceHierarchical('dashboard.*', 'dashboard')         // false - parent excluded

matchesScope

function matchesScope(
  pattern: string | undefined | null,
  scope: string | undefined | null,
): boolean

An absent pattern (undefined or null) or '*' matches any scope — that is a global permission. Otherwise the request must carry a scope, and it must match exactly.

The checks are explicit, not truthiness: '' is a scope value, not a missing one. Reading an empty pattern as global made a row with scope: '' grant across every scope, the opposite of what it looks like. validateRole now refuses scope: '' on the write path for the same reason.

matchesScope(null, null)          // true  - global permission, no scope asked for
matchesScope(undefined, 'org-1')  // true  - global permission covers a scoped request
matchesScope('*', 'org-1')        // true
matchesScope('org-1', 'org-1')    // true
matchesScope('org-1', 'org-2')    // false
matchesScope('org-1', null)       // false - scoped permission, unscoped request
matchesScope('', 'org-1')         // false - '' is a scope, not "global"

'*' is global here because this is a declared scope on a role or permission. On an assignment it is matched literally, which is why iamAssertAssignableScope refuses both '' and '*' on the write path.

Field resolution

resolve

function resolve(
  request: IamRequest.IAccessRequest,
  path: string,
  caches?: { path?: Map<string, string[] | null> },
): IamPrimitives.AttributeValue

Resolves a dot-path against an access request. IamPrimitives.AttributeValue is string | number | boolean | null and the array forms.

BehaviourDetail
RootsALLOWED_ROOTS = subject, resource, environment. Anything else resolves to null.
Shorthands'action' returns request.action; 'scope' returns request.scope ?? null. Both are whole-path matches, checked before segment splitting.
Blocked segments__proto__, constructor, prototype at any position make the whole path resolve to null.
MissingA missing property resolves to null, never undefined.
cachesOptional per-engine segment cache. Omit it and the process-wide pathCache is used.
import { resolve } from '@gentleduck/iam'

const request = {
  subject: { id: 'u-1', roles: ['editor'], attributes: { department: 'eng' } },
  action: 'update',
  resource: { type: 'post', id: 'p-5', attributes: { ownerId: 'u-1' } },
  environment: { ip: '10.0.0.1' },
}

resolve(request, 'subject.id')                     // 'u-1'
resolve(request, 'subject.roles')                  // ['editor']
resolve(request, 'subject.attributes.department')  // 'eng'
resolve(request, 'resource.attributes.ownerId')    // 'u-1'
resolve(request, 'environment.ip')                 // '10.0.0.1'
resolve(request, 'action')                         // 'update'
resolve(request, 'scope')                          // null
resolve(request, 'invalid.path')                   // null - unknown root
resolve(request, 'subject.__proto__.x')            // null - blocked segment

A null here is indistinguishable from a genuinely-null attribute, which is why validatePolicy warns with UNRESOLVABLE_FIELD at authoring time rather than leaving you to debug it at request time. See validation.

PATH_CACHE_MAX and clearPathCache

const PATH_CACHE_MAX: number   // 10_000
function clearPathCache(): void

resolve memoizes the split-and-validate step per path string. Entries are evicted first-in-first-out once the map reaches PATH_CACHE_MAX; at roughly 200 bytes an entry that caps the cache near 2 MB. Multi-tenant deployments should pass a per-engine cache through the caches argument so one tenant's path churn cannot evict another's, and can call clearPathCache() to flush the shared one periodically.

The map itself (pathCache) and the root set (ALLOWED_ROOTS) are not exported — see what is deliberately not exported. ALLOWED_ROOTS is nevertheless the same set isResolvablePath in the validator consults, and the two share BLOCKED_SEGMENTS as well, which is what keeps the UNRESOLVABLE_FIELD warning honest about __proto__ paths as well as typo'd roots.

Condition primitives

iamEvaluateOperator

function iamEvaluateOperator(
  op: AccessControl.Operator,
  fieldValue: IamPrimitives.AttributeValue,
  condValue: IamPrimitives.AttributeValue,
): boolean

A thin wrapper over the internal operator table. It applies the operator directly — no $-resolution, no operand-type guard, and none of iamEvalCondition's refusal of a user-sourced matches pattern.

import { iamEvaluateOperator as op } from '@gentleduck/iam'

op('eq', 'admin', 'admin')                  // true
op('neq', 'viewer', 'admin')                // true
op('gt', 10, 5)                             // true
op('gt', '10', 5)                           // false - both must be numbers
op('in', 'editor', ['admin', 'editor'])     // true
op('contains', ['a', 'b', 'c'], 'b')        // true - array holds the value
op('contains', 'hello world', 'world')      // true - substring
op('starts_with', 'hello', 'he')            // true
op('matches', 'user-123', '^user-\\d+$')    // true
op('exists', 'anything', null)              // true - field is not null
op('not_exists', null, null)                // true
op('subset_of', ['a'], ['a', 'b'])          // true
op('superset_of', ['a', 'b'], ['a'])        // true
op('before', '2024-01-01', '2025-01-01')    // true - ISO strings coerced

There are nineteen operators: eq, neq, gt, gte, lt, lte, in, nin, contains, not_contains, starts_with, ends_with, matches, exists, not_exists, subset_of, superset_of, before, after. Full semantics, including the missing / NaN / empty-value edge cases, live on condition operators.

Two behaviours matter when calling the primitive directly. The numeric comparisons (gt, gte, lt, lte) require both operands to be number and return false otherwise. And matches throws IamRegexInputTooLargeError when the input string exceeds IAM_MAX_REGEX_INPUT_LENGTH (2048) rather than returning false, so a deny-on-matches rule cannot be flipped by an oversized input.

iamEvaluateOperator is the only handle on the operator table. The table itself is not exported, because assigning to one of its entries retires every rule using that operator in both evaluation modes.

iamResolveConditionValue and iamResolveValue

function iamResolveConditionValue(
  req: IamRequest.IAccessRequest,
  value: IamPrimitives.AttributeValue,
): IamPrimitives.AttributeValue

function iamResolveValue(
  req: IamRequest.IAccessRequest,
  value: IamPrimitives.AttributeValue,
  caches?: { path?: Map<string, string[] | null> },
): IamPrimitives.AttributeValue

iamResolveConditionValue is the two-argument convenience form; iamResolveValue is the same function with the optional per-engine path cache. Both resolve a $-prefixed string by stripping the $ and running the remainder through resolve. Any other value passes through untouched.

import { iamResolveConditionValue } from '@gentleduck/iam'

const request = {
  subject: { id: 'u-1', roles: ['editor'], attributes: {} },
  action: 'update',
  resource: { type: 'post', attributes: { ownerId: 'u-1' } },
}

iamResolveConditionValue(request, '$subject.id')                   // 'u-1'
iamResolveConditionValue(request, '$resource.attributes.ownerId')  // 'u-1'
iamResolveConditionValue(request, '$nope.field')                   // null - unknown root
iamResolveConditionValue(request, 'literal-string')                // 'literal-string'
iamResolveConditionValue(request, 42)                              // 42

A $-reference that resolves to null is not the same as a literal null operand. iamEvalCondition throws IamOperandTypeError on the former, because resolve answers null for "unresolvable path", "attribute absent" and "explicitly null" alike, and eq is a bare === — so subject.attributes.tenant eq $resource.attributes.tenant compared null === null and allowed a request carrying neither attribute. A literal value: null is an author explicitly testing for null and still works.

See dollar variables for how these references are authored, and typed dollar paths for the compile-time version.

iamIsUserSourcedValue

function iamIsUserSourcedValue(value: IamPrimitives.AttributeValue): boolean

true when the value is a string starting with $. iamEvalCondition uses it to refuse a matches condition whose right-hand side is $-resolved: a user-controlled attribute must never become a compiled regex.

Such a leaf throws IamUserSourcedPatternError — it does not evaluate to false. Answering false reads as "condition not met", and the rule holding it is as likely to be a deny as an allow: a deny when email matches $resource.attributes.bannedPattern rule never fired and the banned subject came back ALLOWED, with nothing reported to onPolicyError. Inside a none the negation turns it into a grant outright. Indeterminate is the only answer that does not depend on which effect the rule carries.

iamIsUserSourcedValue('$subject.id')  // true
iamIsUserSourcedValue('^user-\\d+$')  // false
iamIsUserSourcedValue(42)             // false

iamIsCondition

function iamIsCondition(
  item: AccessControl.ICondition | AccessControl.IConditionGroup,
): item is AccessControl.ICondition

Type guard distinguishing a leaf from a group: a leaf has a field key. Use it when walking a condition tree you built yourself.

iamEvalCondition and iamEvalConditionGroup

function iamEvalCondition(
  req: IamRequest.IAccessRequest,
  cond: AccessControl.ICondition,
  caches?: { regex?: Map<string, RegExp>; path?: Map<string, string[] | null> },
): boolean

function iamEvalConditionGroup(
  req: IamRequest.IAccessRequest,
  group: AccessControl.IConditionGroup,
  depth = 0,
  caches?: { regex?: Map<string, RegExp>; path?: Map<string, string[] | null> },
): boolean

The real evaluation entry points, as opposed to the raw iamEvaluateOperator. iamEvalCondition refuses a user-sourced matches pattern, resolves both sides, applies the operand-type table, then dispatches. iamEvalConditionGroup walks all / any / none.

GroupResult
{ all: [...] }every item true
{ any: [...] }at least one item true
{ none: [...] }no item true
{} (literally no keys)true no conditions means unconditional
depth >= IAM_MAX_CONDITION_DEPTHthrows IamConditionGroupError('depth', …)
any other key set (a typo'd all, a hand-edited row)throws IamConditionGroupError('unknown-keys', …)

iamGetCachedRegex, iamClearRegexCache, and the regex constants

function iamGetCachedRegex(pattern: string, cache?: Map<string, RegExp>): RegExp | null
function iamClearRegexCache(): void
const IAM_REGEX_CACHE_MAX: number         // 256
const IAM_MAX_REGEX_LENGTH: number        // 128 - longest pattern accepted
const IAM_MAX_REGEX_INPUT_LENGTH: number  // 2048 - longest input matched against one
const IAM_MAX_CONDITION_DEPTH: number     // 10

iamGetCachedRegex compiles a matches pattern once and stores it in an LRU keyed by the pattern string; a hit re-inserts the entry to refresh recency. It returns null — rather than a compiled regex — for any pattern iamDetectCatastrophicRegex rejects and for any pattern that does not compile, so a hostile pattern that reached the runtime past the validator never becomes a RegExp. Eviction is least-recently-used, bounded at IAM_REGEX_CACHE_MAX, so pattern churn from one tenant cannot grow memory without bound. Pass a per-engine cache to keep tenants from evicting each other, or call iamClearRegexCache() to flush the process-wide one.

The cache map itself is not exported: seating a permissive RegExp under a pattern a deny rule relies on is a one-line bypass.

Explain helper

iamEscapeHtml

function iamEscapeHtml(s: string): string

Replaces &, <, >, ", ' with HTML entities, ampersand first so nothing double-escapes. It exists for exactly one job: the explain trace's summary and its condition actual / expected strings carry operator-supplied policy names and request-derived attribute values verbatim, and the explain pipeline never escapes for any rendering target.

import { iamEscapeHtml } from '@gentleduck/iam/core/explain'

const trace = await engine.explain('u-1', 'read', { type: 'post' })
panel.innerHTML = `<pre>${iamEscapeHtml(trace.summary)}</pre>`

Cache and key helpers

These four are exported from the package root only.

IamLRUCache and iamLRUCache

class IamLRUCache<V> {
  constructor(maxSize: number, ttlMs: number)
  get(key: string): V | undefined
  set(key: string, value: V): void
  delete(key: string): boolean
  clear(): void
  resetStats(): void
  get stats(): { hits: number; misses: number; size: number }
  get size(): number
  entries(): IterableIterator<[string, V]>
}

function iamLRUCache<V>(maxSize: number, ttlMs: number): IamLRUCache<V>

The LRU with TTL the engine uses for its policy, role, RBAC-policy, merged-policy, and subject caches. iamLRUCache is the factory form for callers who prefer functions to new.

MemberBehaviour
constructorThrows RangeError when maxSize is not finite or below 1, or when ttlMs is not finite or negative.
getRefreshes recency on a hit. A miss or an expired entry returns undefined and increments misses; an expired entry is also deleted.
setDeletes then re-inserts, so it refreshes both recency and TTL. Evicts the oldest entry at capacity.
clearDrops entries but keeps the hit/miss counters.
resetStatsZeroes the counters but keeps the entries.
entriesYields non-expired entries and does not refresh recency.
import { iamLRUCache } from '@gentleduck/iam'

const cache = iamLRUCache<string[]>(500, 30_000) // 500 entries, 30s TTL
cache.set('u-1', ['editor'])
cache.get('u-1')      // ['editor']
cache.stats           // { hits: 1, misses: 0, size: 1 }

iamBuildPermissionKey, iamParsePermissionKey, iamSplitPermissionKey

function iamBuildPermissionKey(
  action: string,
  resource: string,
  resourceId?: string,
  scope?: string,
): string

function iamParsePermissionKey(key: string): {
  scope: string | undefined
  action: string
  resource: string
  resourceId: string | undefined
} | null

function iamSplitPermissionKey(key: string): string[]

The permission-map key format is [@scope:]action:resource[:resourceId]. The @ on the scope is load-bearing: without it, ('read', 'doc', '42') and ('doc', '42', undefined, 'read') both produced read:doc:42, so two different checks in one checkMany shared a map entry and one could answer for the other. A scoped key is the only one that may start with an unescaped @, which is what makes the arity unambiguous.

Within each segment a literal \ becomes \\, a literal : becomes \:, and a leading @ is escaped too, so a resource id containing a colon cannot forge an extra segment. iamSplitPermissionKey reverses that, honouring only those escape sequences — an attacker-crafted \x stays \x rather than becoming x. iamParsePermissionKey goes further and returns the named fields, or null for a string that is not a well-formed key, so a hand-built one is rejected rather than guessed at.

import { iamBuildPermissionKey, iamParsePermissionKey } from '@gentleduck/iam'

iamBuildPermissionKey('read', 'document')
// 'read:document'
iamBuildPermissionKey('read', 'document', 'doc-1')
// 'read:document:doc-1'
iamBuildPermissionKey('write', 'doc', 'a:42', 'tenant_a')
// '@tenant_a:write:doc:a\\:42'

iamParsePermissionKey('@tenant_a:write:doc:a\\:42')
// { scope: 'tenant_a', action: 'write', resource: 'doc', resourceId: 'a:42' }

See permission maps for how these keys reach the client.

Row parsers for adapter authors

Writing a custom adapter means crossing from unknown (JSON, a SQL column, a Redis blob) into the typed domain. Do it through the parsers, never through a cast:

import { parsePolicyRow, parseRoleRow } from '@gentleduck/iam/core/validate'
import type { AccessControl, IamAdapter } from '@gentleduck/iam'

class MyAdapter implements IamAdapter.IAdapter {
  async listPolicies(): Promise<AccessControl.IPolicy[]> {
    const rows = await this.store.fetchPolicies()
    const out: AccessControl.IPolicy[] = []
    for (const row of rows) {
      const policy = parsePolicyRow(row)
      if (policy === null) {
        // Refuse, do not skip: the dropped policy may have been the one that denies.
        this.onPolicyError?.(new Error(`policy "${row.id}" cannot be read`))
        throw new Error(`policy "${row.id}" cannot be read`)
      }
      out.push(policy)
    }
    return out
  }
}

parsePolicyRow returns null on any error-level validation failure; warnings still return the row, and on success it returns the identical object rather than a copy.

Typical reasons to need this:

  • A JSON-column SQL adapter whose rows can be edited by hand.
  • A migration tool copying rows between stores, where dropping is better than crashing.
  • A staging endpoint that previews operator-proposed policies before commit.

See custom adapters for the full interface and validation for what counts as malformed.

Gotchas

  • The matchers are not symmetric. matchesResource accepts both ':*' and '.*'; matchesResourceHierarchical accepts only '.*'. The evaluator picks between them on whether either side contains a dot, so a colon-style resource type never reaches the hierarchical matcher.
  • resolve returns null, not undefined, for both "path invalid" and "value missing". Downstream operators like exists treat the two identically.
  • iamEvaluateOperator skips every guard. Only iamEvalCondition refuses a $-resolved regex and applies the operand-type table. If you build a custom pipeline on the raw operator, check iamIsUserSourcedValue and the operand type yourself — a wrongly typed operand made nin short-circuit to true, so an "allow unless denylisted" rule admitted everyone.
  • The process-wide caches are shared and not exported. In a multi-tenant process, pass per-engine caches through the caches argument; flush the shared ones with clearPathCache() and iamClearRegexCache().
  • IamLRUCache.clear() keeps stats; resetStats() keeps entries. They are deliberately independent.
  • Engine-supplied policies are deep-frozen. The synthetic RBAC policy the engine caches is recursively Object.freezed — the policy, its rules array, each rule, each rule's actions and resources arrays, and the whole condition tree. evaluate, evaluateFast, and explain all read the same reference, so mutating one would corrupt every later request. In strict mode the write throws; build your own copy instead.

See also