Skip to main content

$-variable references

Compare two fields on the same request - which $-paths resolve, from which object, at what point in evaluation, and what a missing path does to each operator

A condition normally compares a resolved field against a literal. A $-prefixed string value makes the right-hand side a path too, so one condition can compare two parts of the same request - the resource's owner against the subject's id, the resource's department against the subject's department, an expiry against the clock.

The rule in one line

A condition value is resolved as a path only when it is a string whose first character is $. Everything else - numbers, booleans, null, arrays, objects, and strings that do not start with $ - is used literally.

// resource.attributes.ownerId === request.subject.id
.when((w) => w.check('resource.attributes.ownerId', 'eq', '$subject.id'))

// literal: resource.attributes.ownerId === the seven-character string 'subject'
.when((w) => w.check('resource.attributes.ownerId', 'eq', 'subject'))

At evaluation time the engine strips the $, resolves the remainder against the request with the same resolve() used for the field side, and hands both values to the operator. The field side is always a path and never takes a $.

isOwner() is a shorthand for exactly this pattern:

w.isOwner()
// { field: 'resource.attributes.ownerId', operator: 'eq', value: '$subject.id' }

w.isOwner('resource.attributes.createdBy')
// { field: 'resource.attributes.createdBy', operator: 'eq', value: '$subject.id' }

When resolution happens

$ values are resolved per condition, per request, against the request object as it exists at that moment - after subject resolution, after scoped-role enrichment, after your beforeEvaluate hook, and after the engine has defaulted environment.now.

Loading diagram...

Three things follow from the ordering in the diagram:

  • $subject.roles is the effective role set, not the raw assignment: inheritance is already closed over and matching scoped roles are already merged in. See role inheritance and scoped roles.
  • beforeEvaluate can change what a <MathMl mathml="<span class=&quot;katex&quot;><span class=&quot;katex-mathml&quot;><math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;><semantics><mrow><mi mathvariant=&quot;normal&quot;>‘</mi><mi>p</mi><mi>a</mi><mi>t</mi><mi>h</mi><mi>s</mi><mi>e</mi><mi>e</mi><mi>s</mi><mi mathvariant=&quot;normal&quot;>.</mi><mo>∗</mo><mo>∗</mo><mi>A</mi><mi>h</mi><mi>o</mi><mi>o</mi><mi>k</mi><mi>t</mi><mi>h</mi><mi>a</mi><mi>t</mi><mi>i</mi><mi>n</mi><mi>j</mi><mi>e</mi><mi>c</mi><mi>t</mi><mi>s</mi><mi mathvariant=&quot;normal&quot;>‘</mi><mi>e</mi><mi>n</mi><mi>v</mi><mi>i</mi><mi>r</mi><mi>o</mi><mi>n</mi><mi>m</mi><mi>e</mi><mi>n</mi><mi>t</mi><mi mathvariant=&quot;normal&quot;>.</mi><mi>t</mi><mi>e</mi><mi>n</mi><mi>a</mi><mi>n</mi><mi>t</mi><mi>I</mi><mi>d</mi><mi mathvariant=&quot;normal&quot;>‘</mi><mi>m</mi><mi>a</mi><mi>k</mi><mi>e</mi><mi>s</mi><mi mathvariant=&quot;normal&quot;>‘</mi></mrow><annotation encoding=&quot;application/x-tex&quot;> path sees. A hook that injects environment.tenantId makes </annotation></semantics></math></span><span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:0.8889em;vertical-align:-0.1944em;&quot;></span><span class=&quot;mord&quot;>‘</span><span class=&quot;mord mathnormal&quot;>p</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>h</span><span class=&quot;mord mathnormal&quot;>sees</span><span class=&quot;mord&quot;>.</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2222em;&quot;></span><span class=&quot;mbin&quot;>∗</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2222em;&quot;></span></span><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:0.8889em;vertical-align:-0.1944em;&quot;></span><span class=&quot;mord&quot;>∗</span><span class=&quot;mord mathnormal&quot;>A</span><span class=&quot;mord mathnormal&quot;>h</span><span class=&quot;mord mathnormal&quot;>oo</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03148em;&quot;>k</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>ha</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.05724em;&quot;>inj</span><span class=&quot;mord mathnormal&quot;>ec</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord&quot;>‘</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>n</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>v</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>ro</span><span class=&quot;mord mathnormal&quot;>nm</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>n</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord&quot;>.</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>nan</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.07847em;&quot;>I</span><span class=&quot;mord mathnormal&quot;>d</span><span class=&quot;mord&quot;>‘</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03148em;&quot;>mak</span><span class=&quot;mord mathnormal&quot;>es</span><span class=&quot;mord&quot;>‘</span></span></span></span>"/>environment.tenantId resolvable for every policy in that request.
  • $environment.now always resolves. The engine sets environment.now to Date.now() in epoch milliseconds when the caller did not supply one, and never overwrites a supplied value - so a test or a replay hook can pin the clock. This is what pairs with the before and after operators.

Nothing is resolved at build time. definePolicy(...).build() stores the literal string '$subject.id'; the same policy JSON works for every request.

Which paths resolve

The $ side accepts the same roots as the field side.

$-pathResolves toNotes
$actionrequest.actionWhole-path shorthand, not a root
$scoperequest.scope, or null when the request has no scopeWhole-path shorthand
$subject.idThe subject id
$subject.rolesThe effective roles arrayAn array value - see "Arrays" below
$subject.attributes.<key>A subject attribute, at any depth
<MathMl mathml="<span class=&quot;katex&quot;><span class=&quot;katex-mathml&quot;><math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;><semantics><mrow><mi>s</mi><mi>u</mi><mi>b</mi><mi>j</mi><mi>e</mi><mi>c</mi><mi>t</mi><mi mathvariant=&quot;normal&quot;>.</mi><mi>s</mi><mi>c</mi><mi>o</mi><mi>p</mi><mi>e</mi><mi>d</mi><mi>R</mi><mi>o</mi><mi>l</mi><mi>e</mi><mi>s</mi><mi mathvariant=&quot;normal&quot;>.</mi><mo>&amp;lt;</mo><mi>i</mi><mo>&amp;gt;</mo><mi mathvariant=&quot;normal&quot;>.</mi><mi>a</mi><mi>t</mi><mi>t</mi><mi>r</mi><mi>i</mi><mi>b</mi><mi>u</mi><mi>t</mi><mi>e</mi><mi>s</mi><mi mathvariant=&quot;normal&quot;>.</mi><mo>&amp;lt;</mo><mi>k</mi><mi>e</mi><mi>y</mi><mo>&amp;gt;</mo><mi mathvariant=&quot;normal&quot;>‘</mi><mi mathvariant=&quot;normal&quot;>∣</mi><mi>A</mi><mi>p</mi><mi>e</mi><mi>r</mi><mo>−</mo><mi>a</mi><mi>s</mi><mi>s</mi><mi>i</mi><mi>g</mi><mi>n</mi><mi>m</mi><mi>e</mi><mi>n</mi><mi>t</mi><mi>a</mi><mi>t</mi><mi>t</mi><mi>r</mi><mi>i</mi><mi>b</mi><mi>u</mi><mi>t</mi><mi>e</mi><mi mathvariant=&quot;normal&quot;>∣</mi><mi>T</mi><mi>h</mi><mi>e</mi><mi>b</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi mathvariant=&quot;normal&quot;>‘</mi></mrow><annotation encoding=&quot;application/x-tex&quot;>subject.scopedRoles.&amp;lt;i&amp;gt;.attributes.&amp;lt;key&amp;gt;A per-assignment attributeThe bare </annotation></semantics></math></span><span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:0.8889em;vertical-align:-0.1944em;&quot;></span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.05724em;&quot;>bj</span><span class=&quot;mord mathnormal&quot;>ec</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord&quot;>.</span><span class=&quot;mord mathnormal&quot;>sco</span><span class=&quot;mord mathnormal&quot;>p</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>d</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.00773em;&quot;>R</span><span class=&quot;mord mathnormal&quot;>o</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot;>es</span><span class=&quot;mord&quot;>.</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2778em;&quot;></span><span class=&quot;mrel&quot;>&amp;lt;</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2778em;&quot;></span></span><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:0.6986em;vertical-align:-0.0391em;&quot;></span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2778em;&quot;></span><span class=&quot;mrel&quot;>&amp;gt;</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2778em;&quot;></span></span><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:0.7335em;vertical-align:-0.0391em;&quot;></span><span class=&quot;mord&quot;>.</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot;>tt</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02778em;&quot;>r</span><span class=&quot;mord mathnormal&quot;>ib</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>es</span><span class=&quot;mord&quot;>.</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2778em;&quot;></span><span class=&quot;mrel&quot;>&amp;lt;</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2778em;&quot;></span></span><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:0.8889em;vertical-align:-0.1944em;&quot;></span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03148em;&quot;>k</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>ey</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2778em;&quot;></span><span class=&quot;mrel&quot;>&amp;gt;</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2778em;&quot;></span></span><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:1em;vertical-align:-0.25em;&quot;></span><span class=&quot;mord&quot;>‘∣</span><span class=&quot;mord mathnormal&quot;>A</span><span class=&quot;mord mathnormal&quot;>p</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02778em;&quot;>er</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2222em;&quot;></span><span class=&quot;mbin&quot;>−</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.2222em;&quot;></span></span><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:1em;vertical-align:-0.25em;&quot;></span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot;>ss</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>g</span><span class=&quot;mord mathnormal&quot;>nm</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>n</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot;>tt</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02778em;&quot;>r</span><span class=&quot;mord mathnormal&quot;>ib</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord&quot;>∣</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.13889em;&quot;>T</span><span class=&quot;mord mathnormal&quot;>h</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>ba</span><span class=&quot;mord mathnormal&quot;>re</span><span class=&quot;mord&quot;>‘</span></span></span></span>"/>subject.scopedRoles is null - an array of objects is outside AttributeValue
<MathMl mathml="<span class=&quot;katex&quot;><span class=&quot;katex-mathml&quot;><math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;><semantics><mrow><mi>r</mi><mi>e</mi><mi>s</mi><mi>o</mi><mi>u</mi><mi>r</mi><mi>c</mi><mi>e</mi><mi mathvariant=&quot;normal&quot;>.</mi><mi>t</mi><mi>y</mi><mi>p</mi><mi>e</mi><mi mathvariant=&quot;normal&quot;>‘</mi><mi mathvariant=&quot;normal&quot;>/</mi><mi mathvariant=&quot;normal&quot;>‘</mi></mrow><annotation encoding=&quot;application/x-tex&quot;>resource.type / </annotation></semantics></math></span><span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:1em;vertical-align:-0.25em;&quot;></span><span class=&quot;mord mathnormal&quot;>reso</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>rce</span><span class=&quot;mord&quot;>.</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>y</span><span class=&quot;mord mathnormal&quot;>p</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord&quot;>‘/‘</span></span></span></span>"/>resource.idResource type and instance id
$resource.attributes.<key>A resource attribute, at any depth
$environment.<key>ip, userAgent, timestamp, now, or any custom key

Resolution rules:

  • The only permitted roots are subject, resource, and environment, plus the two shorthands. $nope.x resolves to null - not to the literal string.
  • __proto__, constructor, and prototype are refused at any segment; the whole path resolves to null. $__proto__.x is null.
  • Traversal stops at the first non-object and undefined becomes null. $resource.attributes.missing is null.
  • The resolved value is narrowed, not asserted. It must be a scalar, an array of scalars, or a flat object of scalars; anything else - a Date, a nested object, an array of objects - becomes null. So <MathMl mathml="<span class=&quot;katex&quot;><span class=&quot;katex-mathml&quot;><math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;><semantics><mrow><mi>s</mi><mi>u</mi><mi>b</mi><mi>j</mi><mi>e</mi><mi>c</mi><mi>t</mi><mi mathvariant=&quot;normal&quot;>‘</mi><mi>a</mi><mi>n</mi><mi>d</mi><mi mathvariant=&quot;normal&quot;>‘</mi></mrow><annotation encoding=&quot;application/x-tex&quot;>subject and </annotation></semantics></math></span><span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:0.8889em;vertical-align:-0.1944em;&quot;></span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.05724em;&quot;>bj</span><span class=&quot;mord mathnormal&quot;>ec</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord&quot;>‘</span><span class=&quot;mord mathnormal&quot;>an</span><span class=&quot;mord mathnormal&quot;>d</span><span class=&quot;mord&quot;>‘</span></span></span></span>"/>resource are null, not the object.
  • Path splitting is memoised in a FIFO cache of PATH_CACHE_MAX (10_000) entries, per engine instance when the engine supplies one.
// against a request with subject u1 (roles editor, admin; department eng),
// resource post p1 (ownerId u1), action 'update', no scope:
'$subject.id'                     // 'u1'
'$subject.roles'                  // ['editor', 'admin']
'$subject.attributes.department'  // 'eng'
'$resource.attributes.missing'    // null
'$action'                         // 'update'
'$scope'                          // null  (request has no scope)
'$nope.x'                         // null  (unknown root)
'$__proto__.x'                    // null  (blocked segment)
'$subject'                        // null  (an object with a roles array is not an AttributeValue)
'subject.id'                      // 'subject.id'  (no $, so a literal)

Where $ values are accepted

Anywhere the builder takes a value: check(), eq(), neq(), in(), gt/gte/lt/lte, contains(), and the attribute shorthands attr(), resourceAttr(), env(). The builder does not treat them specially - it stores the string, and the evaluator resolves it.

// Resource owner must not be the subject (four-eyes review)
.when((w) => w.resourceAttr('ownerId', 'neq', '$subject.id'))

// Subject and resource must share a department
.when((w) => w.check('resource.attributes.department', 'eq', '$subject.attributes.department'))

// Subject's clearance must reach the document's level
.when((w) => w.check('subject.attributes.clearance', 'gte', '$resource.attributes.classificationLevel'))

// The request scope must be the resource's tenant
.when((w) => w.check('resource.attributes.orgId', 'eq', '$scope'))

// Grant is still in the future
.when((w) => w.check('subject.attributes.suspendedUntil', 'after', '$environment.now'))

Two operators refuse or ignore the $ form:

  • matches refuses it. A matches condition whose value starts with $ throws IamUserSourcedPatternError before the reference is resolved, so no attacker-controlled attribute can ever supply the regex. It throws rather than answering false because false reads as "condition not met": measured through engine.can on a seeded policy, deny when email matches $resource.attributes.bannedPattern never fired and the banned subject was allowed, with nothing reported to onPolicyError. Details on conditions.
  • Array literals are not resolved element by element. w.in('resource.attributes.ownerId', ['$subject.id']) compares against the literal seven-character string '$subject.id', not the subject id, and is therefore always false. Only a whole value that is a $-string is resolved.

A $ path that resolves to an array does work with the set operators, because the resolved value is a real array:

// true when subject.attributes.tier is one of the subject's own roles
.when((w) => w.check('subject.attributes.tier', 'in', '$subject.roles'))

// true when any of the subject's roles is in the resource's allow-list
.when((w) => w.check('subject.roles', 'in', '$resource.attributes.allowedRoles'))

The second form is the useful one: with an array on both sides, in is an overlap test.

A $ path that resolves to nothing is refused

resolve() never throws: an unknown root, a blocked segment, a typo, or an attribute the request did not carry all produce null. What happens next depends on which side of the condition the null is on, and the two sides are treated differently on purpose.

On the field (left-hand) side, null is a match failure. No guard runs; each operator's own typeof test decides, and most answer false. The four negated operators (neq, nin, not_contains, not_exists) answer true. That table is on conditions.

On the operand (right-hand) side, a $-reference that resolves to null is a refusal. evalCondition throws IamOperandTypeError naming the field, the operator, and the reference that resolved to nothing. The engine reports it through hooks.onPolicyError and the policy becomes Indeterminate: it still votes, and a policy carrying any deny rule votes deny. Under the default policyCombine: 'and' that deny is final.

Loading diagram...

This is what fixed the canonical multi-tenant guard. subject.attributes.tenant eq $resource.attributes.tenant used to compare null === null and allow a request that carried neither attribute - through the fully validated authoring path, since the validator cannot type a $-reference and so had nothing to say about it. The refusal is scoped to $-references on purpose: a literal value: null is an author explicitly testing for null and still works.

Two consequences worth planning for:

  • A $-comparison against optional data now takes the whole policy Indeterminate, rather than granting silently. If $subject.attributes.tenantId is genuinely optional, guard the comparison with exists on both sides so the rule declines to match instead of refusing:

    .when((w) => w
      .exists('resource.attributes.tenantId')
      .exists('subject.attributes.tenantId')
      .check('resource.attributes.tenantId', 'eq', '$subject.attributes.tenantId'),
    )
    

    The all group short-circuits at the first false exists, so the refusing leaf is never reached.

  • $environment.now needs the engine. The engine injects environment.now = Date.now() after beforeEvaluate, so a hook-pinned clock survives. A request built by hand and passed straight to iamEvaluate gets no such injection, and a temporal rule against $environment.now on it throws.

The validator catches typos but not absent data. A $ value whose root is not resolvable produces the warning UNRESOLVABLE_VALUE - Condition value "$foo.bar" references an unresolvable path. Warnings do not make build() throw, so read them: see validation.

Typed $-paths

With a typed context, $ values autocomplete and are checked at compile time. The paths come from DotPath.DollarPaths<TContext>, which prefixes a $ onto every member of DotPath.DotPaths<TContext> - that is, every reachable path through the context object.

import { createIam } from '@gentleduck/iam'

const access = createIam({
  actions: ['read', 'update'] as const,
  resources: ['post'] as const,
  context: {} as {
    action: string
    scope: string
    subject: { id: string; roles: string[]; attributes: { tier: 'free' | 'pro'; department: string } }
    resource: { type: 'post'; id: string; attributes: { ownerId: string; tier: 'free' | 'pro'; department: string } }
    environment: { hour: number; now: number }
  },
})

const p = access
  .definePolicy('post-tier')
  .rule('match-tier', (r) =>
    r
      .allow()
      .on('read')
      .of('post')
      // the editor offers '$subject.attributes.tier' here
      .when((w) => w.check('resource.attributes.tier', 'eq', '$subject.attributes.tier')),
  )
  .build()

Builder methods take DotPath.FlexibleDollarPaths<TContext>, which is DollarPaths<TContext> | (string & {}). The & {} intersection keeps the literal suggestions visible in the editor while still accepting any string, so a path your context does not model is a runtime null rather than a compile error. Two consequences:

  • Open attribute bags stop at the bag. With the default DotPath.IDefaultContext the attribute bags are index-signature types, so DotPaths stops at subject.attributes and no per-key suggestions appear. Declare concrete attribute shapes, as above, to get them.
  • Arrays are leaves. $subject.roles is offered; $subject.roles.0 is not derived, though it resolves fine at runtime.

Full type machinery on typed dollar-paths and typed context.

Common patterns

Owner-only edits

.rule('owner-edit', (r) =>
  r
    .allow()
    .on('update', 'delete')
    .of('post')
    .when((w) => w.isOwner()),
)

Same-tenant access, presence-checked

.rule('tenant-isolation', (r) =>
  r
    .allow()
    .on('*')
    .of('document')
    .when((w) =>
      w
        .exists('resource.attributes.tenantId')
        .check('resource.attributes.tenantId', 'eq', '$subject.attributes.tenantId'),
    ),
)

Prevent self-actions

.rule('no-self-delete', (r) =>
  r
    .deny()
    .on('delete')
    .of('user')
    .when((w) => w.check('resource.id', 'eq', '$subject.id')),
)

A deny rule is the right shape here: resource.id is on the field side, so a missing id is a match failure, the deny does not fire, and the request falls back to whatever else the policy set decides. $subject.id on the operand side always resolves, because the engine cannot assemble a request without a subject.

Clearance must reach classification

.rule('insufficient-clearance', (r) =>
  r
    .deny()
    .on('read')
    .of('document')
    .when((w) =>
      w.check('subject.attributes.clearance', 'lt', '$resource.attributes.classificationLevel'),
    ),
)

The two sides fail differently. A missing or non-numeric subject.attributes.clearance is a field-side match failure, so the deny does not fire. A missing resource.attributes.classificationLevel is an operand-side refusal: lt requires a number, the condition throws, and this policy - which carries a deny rule - votes deny as Indeterminate. Put exists guards ahead of the comparison if you want the rule to decline rather than refuse.

Grant window still open

.rule('suspension-expired', (r) =>
  r
    .deny()
    .on('*')
    .of('*')
    .when((w) => w.check('subject.attributes.suspendedUntil', 'after', '$environment.now')),
)

API reference

Runtime helpers, exported from @gentleduck/iam and @gentleduck/iam/core:

ExportSignatureWhat it does
iamResolveConditionValue(req: IamRequest.IAccessRequest, value: IamPrimitives.AttributeValue) => IamPrimitives.AttributeValueResolves a $-value, passes anything else through
iamResolveValue(req, value, caches?) => IamPrimitives.AttributeValueThe same, with an optional per-engine path cache
resolve(req, path: string, caches?) => IamPrimitives.AttributeValueResolve a bare path (no $); null on any miss
iamIsUserSourcedValue(value: IamPrimitives.AttributeValue) => booleantrue only for a $-prefixed string; the guard matches uses
PATH_CACHE_MAX10_000Path-segment cache cap
clearPathCache() => voidFlush the process-wide path cache

ALLOWED_ROOTS and the pathCache map are deliberately not exported. ALLOWED_ROOTS is typed ReadonlySet but erases to a live Set, so .delete('subject') would make every subject.* path unresolvable and flip the deny rules that read one - and pathCache memoises that, so it would stick.

import { iamResolveConditionValue } from '@gentleduck/iam'

iamResolveConditionValue(request, '$subject.id')  // 'u1'
iamResolveConditionValue(request, 'user-1')       // 'user-1'
iamResolveConditionValue(request, 42)             // 42
iamResolveConditionValue(request, '$nope.nope')   // null

Types:

type DotPath.DollarPaths<TContext> = `$${DotPath.DotPaths<TContext>}`
type DotPath.FlexibleDollarPaths<TContext> = DotPath.DollarPaths<TContext> | (string & {})

Gotchas

  • A $ is only special in the first character of a string condition value. 'price-$100' is a literal; ['$subject.id'] is a literal array.
  • $ on the field side is not a thing - fields are already paths. check('$subject.id', ...) looks for a root named $subject, which is unresolvable, and the validator warns UNRESOLVABLE_FIELD.
  • eq takes a scalar operand, so a $ path that resolves to an array or object is refused as IamOperandTypeError. Use subset_of plus superset_of for set equality.
  • $scope is null for an unscoped request, so check('resource.attributes.orgId', 'eq', '$scope') refuses on every unscoped request and takes its policy out of the decision. Guard the rule with exists('scope'), or use forScope() so the rule only applies to scoped requests.
  • Nothing resolves at build time, so a policy exported to JSON and re-imported behaves identically. See admin export and import.

See also