Skip to main content

Typed $-references

DollarPaths gives condition values typed autocomplete for cross-field comparisons - where they are accepted, how they resolve, and where they silently do not

A condition value that starts with $ is not a literal. At evaluation time the engine strips the $ and resolves the rest as a dot-path against the current request, so a rule can compare one field to another instead of to a constant. With a typed context, those references autocomplete. This page covers the type that produces them, the runtime that consumes them, and the three places where the two do not line up.

The type

export type DollarPaths<TContext> = `$${DotPaths<TContext>}`

export type FlexibleDollarPaths<TContext> = DollarPaths<TContext> | (string & {})

DollarPaths prefixes every member of DotPath.DotPaths<TContext> with $. For a context of { a: string; b: { c: number } } it is '$a' | '$b' | '$b.c', which the type-level test pins exactly.

FlexibleDollarPaths is the one the builders use. The (string & {}) arm is what keeps the literal suggestions visible: a bare string in a union swallows the literals and the IDE shows nothing, while string & {} is treated as a distinct branch so the editor still lists $subject.id, $resource.attributes.ownerId, and the rest. It is spliced directly into each method signature rather than nested inside a computed type, because TypeScript only surfaces the literals when the union is visible at the signature site.

access
  .definePolicy('owner-only')
  .rule('deny-non-owner', (r) =>
    r
      .deny()
      .on('update', 'delete')
      .of('post')
      .when((w) =>
        w
          .resourceAttr('ownerId', 'neq', '$subject.id')
          .attr('department', 'eq', '$resource.attributes.department'),
      ),
  )
  .build()

Build time versus evaluation time

Nothing about a $-reference is resolved while you build a policy. The string is stored verbatim in ICondition.value and serialises to JSON unchanged, which is why a policy loaded from a database behaves identically to one built in code.

Loading diagram...

resolveValue is the whole mechanism: if the value is a string that starts with <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><mo separator=&quot;true&quot;>,</mo><mi>i</mi><mi>t</mi><mi>c</mi><mi>a</mi><mi>l</mi><mi>l</mi><mi>s</mi><mi mathvariant=&quot;normal&quot;>‘</mi><mi>r</mi><mi>e</mi><mi>s</mi><mi>o</mi><mi>l</mi><mi>v</mi><mi>e</mi><mo stretchy=&quot;false&quot;>(</mo><mi>r</mi><mi>e</mi><mi>q</mi><mi>u</mi><mi>e</mi><mi>s</mi><mi>t</mi><mo separator=&quot;true&quot;>,</mo><mi>v</mi><mi>a</mi><mi>l</mi><mi>u</mi><mi>e</mi><mi mathvariant=&quot;normal&quot;>.</mi><mi>s</mi><mi>l</mi><mi>i</mi><mi>c</mi><mi>e</mi><mo stretchy=&quot;false&quot;>(</mo><mn>1</mn><mo stretchy=&quot;false&quot;>)</mo><mo stretchy=&quot;false&quot;>)</mo><mi mathvariant=&quot;normal&quot;>‘</mi><mo separator=&quot;true&quot;>;</mo><mi>o</mi><mi>t</mi><mi>h</mi><mi>e</mi><mi>r</mi><mi>w</mi><mi>i</mi><mi>s</mi><mi>e</mi><mi>i</mi><mi>t</mi><mi>r</mi><mi>e</mi><mi>t</mi><mi>u</mi><mi>r</mi><mi>n</mi><mi>s</mi><mi>t</mi><mi>h</mi><mi>e</mi><mi>v</mi><mi>a</mi><mi>l</mi><mi>u</mi><mi>e</mi><mi>u</mi><mi>n</mi><mi>t</mi><mi>o</mi><mi>u</mi><mi>c</mi><mi>h</mi><mi>e</mi><mi>d</mi><mi mathvariant=&quot;normal&quot;>.</mi><mi>T</mi><mi>h</mi><mi>a</mi><mi>t</mi><mi>m</mi><mi>e</mi><mi>a</mi><mi>n</mi><mi>s</mi><mi>a</mi><mi>l</mi><mi>i</mi><mi>t</mi><mi>e</mi><mi>r</mi><mi>a</mi><mi>l</mi><mi>v</mi><mi>a</mi><mi>l</mi><mi>u</mi><mi>e</mi><mi>b</mi><mi>e</mi><mi>g</mi><mi>i</mi><mi>n</mi><mi>n</mi><mi>i</mi><mi>n</mi><mi>g</mi><mi>w</mi><mi>i</mi><mi>t</mi><mi>h</mi><mi mathvariant=&quot;normal&quot;>‘</mi></mrow><annotation encoding=&quot;application/x-tex&quot;>, it calls resolve(request, value.slice(1)); otherwise it returns the value untouched. That means a literal value beginning with </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&quot;>‘</span><span class=&quot;mpunct&quot;>,</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.1667em;&quot;></span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>c</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>ll</span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord&quot;>‘</span><span class=&quot;mord mathnormal&quot;>reso</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>v</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mopen&quot;>(</span><span class=&quot;mord mathnormal&quot;>re</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>q</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>es</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mpunct&quot;>,</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.1667em;&quot;></span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>v</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord&quot;>.</span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>ce</span><span class=&quot;mopen&quot;>(</span><span class=&quot;mord&quot;>1</span><span class=&quot;mclose&quot;>))</span><span class=&quot;mord&quot;>‘</span><span class=&quot;mpunct&quot;>;</span><span class=&quot;mspace&quot; style=&quot;margin-right:0.1667em;&quot;></span><span class=&quot;mord mathnormal&quot;>o</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>h</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02778em;&quot;>er</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02691em;&quot;>w</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>se</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>re</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02778em;&quot;>r</span><span class=&quot;mord mathnormal&quot;>n</span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord mathnormal&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; style=&quot;margin-right:0.03588em;&quot;>v</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>n</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>o</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>c</span><span class=&quot;mord mathnormal&quot;>h</span><span class=&quot;mord mathnormal&quot;>e</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.13889em;&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;>m</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>an</span><span class=&quot;mord mathnormal&quot;>s</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02778em;&quot;>er</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>v</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.01968em;&quot;>l</span><span class=&quot;mord mathnormal&quot;>u</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot;>b</span><span class=&quot;mord mathnormal&quot;>e</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>g</span><span class=&quot;mord mathnormal&quot;>innin</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>g</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02691em;&quot;>w</span><span class=&quot;mord mathnormal&quot;>i</span><span class=&quot;mord mathnormal&quot;>t</span><span class=&quot;mord mathnormal&quot;>h</span><span class=&quot;mord&quot;>‘</span></span></span></span>"/> cannot be expressed - it will always be treated as a reference.

What resolve() will and will not reach

$-references go through the same resolver as field paths, which is deliberately narrow.

Loading diagram...

Three consequences follow from that diagram:

  • Only five roots exist. subject, resource, and environment are the object roots; action and scope are shorthands handled before the root check. <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>p</mi><mi>o</mi><mi>l</mi><mi>i</mi><mi>c</mi><mi>y</mi><mi mathvariant=&quot;normal&quot;>.</mi><mi>i</mi><mi>d</mi><mi mathvariant=&quot;normal&quot;>‘</mi><mi>o</mi><mi>r</mi><mi mathvariant=&quot;normal&quot;>‘</mi></mrow><annotation encoding=&quot;application/x-tex&quot;>policy.id or </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;>p</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;>i</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03588em;&quot;>cy</span><span class=&quot;mord&quot;>.</span><span class=&quot;mord mathnormal&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.02778em;&quot;>or</span><span class=&quot;mord&quot;>‘</span></span></span></span>"/>request.headers resolve to null.
  • Prototype segments are refused, twice over. The walk reads own properties only, so nothing on the prototype chain is reachable at all; on top of that __proto__, constructor and prototype are rejected at parse time so the path is refused once and memoised as invalid rather than walked per request. Own-property reads also fixed a subtler bug: every Object.prototype member resolved to a function on any object, so an exists-gated allow fired against a subject with no attributes.
  • A miss is null, not an error. A typo in a $-path makes the comparison run against null, which for eq and neq is a silent wrong answer rather than a failure. This is exactly the class of bug a typed context prevents.
  • A value outside the attribute contract also resolves to null. resolve narrows what it found rather than asserting it: scalars, arrays of scalars, and plain objects whose values are scalars pass; a Date, a nested object, or a function does not. Adapters deserialise JSON and hand the result straight through, so those genuinely reach here, and null is NotApplicable rather than a guess.

$environment.now is always available: the engine calls ensureEnvNow(req) after beforeEvaluate, defaulting environment.now to Date.now() when a hook has not pinned one. Pair it with before / after for "is this grant still valid" rules.

Where $-references are accepted

The value parameter of a builder method is typed ... | DotPath.FlexibleDollarPaths<TContext> on exactly six methods. Everything else takes a narrower type, which is where the type surface and the runtime diverge.

Method<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>i</mi><mi>n</mi><mi>t</mi><mi>h</mi><mi>e</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></mrow><annotation encoding=&quot;application/x-tex&quot;> in the 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&quot;>‘</span><span class=&quot;mord mathnormal&quot;>in</span><span class=&quot;mord mathnormal&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;>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>"/> resolved at runtimeNotes
check(field, op, value)yesyesThe general escape hatch; any operator, any path
eq(field, value)yesyes
neq(field, value)yesyes
attr(key, op, value)yesyesKey is a subject-attribute path
resourceAttr(key, op, value)yesyesKey narrows per .of()
env(key, op, value)yesyesKey is an environment path
contains(field, value)no - value: stringyesA $-string compiles as a plain string and resolves normally
matches(field, regex)no - regex: stringrefusedSee the security note below
gt / gte / lt / lteno - value: numbern/aUse check(field, 'gt', '$path') for a dynamic bound
in(field, values)element type allows $noSee the gotcha below
exists / not_existsno value parametern/a
role / roles / scope / scopesno - constrained to TRole / TScopen/aThese emit fixed field paths
isOwner(ownerField?)argument is the field, not the valueyesAlways emits value: '$subject.id'

Common patterns

Ownership

.when((w) => w.resourceAttr('ownerId', 'eq', '$subject.id'))

w.isOwner() is the shorthand and emits exactly { field: 'resource.attributes.ownerId', operator: 'eq', value: '$subject.id' }. Pass a different field to isOwner('resource.attributes.createdBy') when your column is named otherwise.

Cross-field equality

.when((w) => w.attr('department', 'eq', '$resource.attributes.department'))

The subject's department must match the resource's.

Self-action prevention

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

Scope match

.when((w) => w.check('resource.attributes.scope', 'eq', '$scope'))

$scope uses the shorthand root, so it reads request.scope and yields null when the request carried no scope.

Temporal validity

.when((w) => w.check('subject.attributes.suspendedUntil', 'after', '$environment.now'))

Both operands are coerced to epoch milliseconds - numbers pass through, ISO-8601 strings are parsed, and anything else becomes NaN so the comparison fails closed.

Gotchas

  • A $-path that does not exist resolves to null, silently. eq against null is false; neq against null is true. A typo in a deny rule's $-path can therefore turn the rule permanently on. Typed contexts are the defence.
  • The type system does not cross-validate the two sides. check('resource.attributes.tier', 'eq', '$subject.attributes.status') compiles even when the two resolve to disjoint unions, because the value type is a union of the field's type and every $-path. Treat $-references as unchecked on the value side.
  • check() is the widest door. Its field parameter is FlexibleDotPaths, so with an open bag anywhere in your context it accepts any string on both sides. That is what makes it useful for paths you never typed, and what makes it the least protected method.

See also