Skip to main content

$-variable references

Compare two fields on the same request - $subject.id, $resource.attributes.ownerId, $env.ip - for owner checks, attribute matching, and self-action prevention.

What are $-references?

Dollar-prefixed values resolve at evaluation time instead of being literals. Use them to compare two fields on the same request.

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

$subject.id isn't compared literally. At eval time the engine strips $, resolves subject.id from the request, then compares.


isOwner shortcut

The most common pattern - owner check - has a built-in helper:

// These are equivalent:
.when((w) => w.isOwner())
.when((w) => w.check('resource.attributes.ownerId', 'eq', '$subject.id'))

Custom owner field name:

.when((w) => w.isOwner('resource.attributes.createdBy'))
// resource.attributes.createdBy === $subject.id

Where $-references work

$-references work anywhere a value is accepted - .check(), .attr(), .resourceAttr(), .env(), and the shorthand operator methods (.eq, .neq, etc.):

// Compare resource owner to current user
.when((w) => w.resourceAttr('ownerId', 'neq', '$subject.id'))

// Compare subject attribute to resource attribute
.when((w) => w.attr('status', 'eq', '$resource.attributes.status'))

// Prevent self-actions (e.g. user can't delete own account)
.when((w) => w.check('resource.id', 'eq', '$subject.id'))

// Compare env value to resource attribute
.when((w) => w.env('ip', 'eq', '$resource.attributes.allowedIp'))

// Resource department must match subject department
.when((w) => w.check(
  'resource.attributes.department',
  'eq',
  '$subject.attributes.department',
))

// Resource scope must match request scope
.when((w) => w.check('resource.attributes.scope', 'eq', '$scope'))

What can $-paths point at?

The same roots that field paths can use:

  • $subject.* - $subject.id, $subject.attributes.foo, $subject.roles
  • $resource.* - $resource.id, $resource.type, $resource.attributes.foo
  • $environment.* - $environment.ip, $environment.timestamp, $environment.foo
  • $action - shorthand for the action string
  • $scope - shorthand for the scope string

Type-safe autocomplete

When you use defineIam() with a typed context, $-references get full autocomplete in your editor. Type '$' in any value position and your editor suggests all valid paths.

const access = defineIam({
  actions: ['read', 'update'] as const,
  resources: ['post'] as const,
  context: {} as {
    subject: { id: string; attributes: { tier: 'free' | 'pro' } }
    resource: { type: 'post'; attributes: { ownerId: string; tier: 'free' | 'pro' } }
  },
})

access.definePolicy('post-tier').rule('match-tier', (r) =>
  r
    .allow()
    .on('read')
    .of('post')
    // Editor autocompletes "$subject.attributes.tier" here
    .when((w) => w.check('resource.attributes.tier', 'eq', '$subject.attributes.tier')),
)

Internally, the & {} intersection trick is used so that even when the literal type is string, autocomplete still shows $-prefixed suggestions. See the type-safe config docs.


Common patterns

Owner-only edits

.rule('owner-edit', (r) =>
  r
    .allow()
    .on('update', 'delete')
    .of('post')
    .when((w) => w.isOwner()), // resource.attributes.ownerId === $subject.id
)

Same-tenant access

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

Prevent self-deletion

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

Deny when attributes diverge

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