Skip to main content

Building policies

definePolicy(), defineRule(), and when() end to end - every chainable method, what build() validates, and what it throws

This page documents the builder API that produces AccessControl.IPolicy and AccessControl.IRule objects: definePolicy(), defineRule(), when(), and the typed variants returned by createIam().

The three builders

Three classes compose: a PolicyBuilder owns RuleBuilders, and each rule owns a When condition builder. The diagram shows the ownership and where each build() lands.

Loading diagram...

PolicyBuilder.rule() creates a RuleBuilder, runs your callback, and calls its build() for you. RuleBuilder.when() does the same with a When. You only call build() yourself on the outermost builder, or on defineRule() / when() when composing standalone pieces.

definePolicy()

import { definePolicy } from '@gentleduck/iam'

const weekendDeny = definePolicy('deny-weekends')
  .name('Deny on Weekends')
  .desc('Block all write operations on weekends')
  .version(1)
  .algorithm('deny-overrides')
  .rule('r-deny-weekends', (r) =>
    r
      .deny()
      .on('create', 'update', 'delete')
      .of('*')
      .when((w) => w.env('dayOfWeek', 'in', [0, 6])),
  )
  .build()

definePolicy(id) returns a PolicyBuilder. The id doubles as the default name.

Inline rules

const contentPolicy = definePolicy('content-policy')
  .name('Content Policy')
  .algorithm('deny-overrides')
  .rule('allow-read', (r) => r.allow().on('read').of('post', 'comment'))
  .rule('owner-edit', (r) =>
    r
      .allow()
      .on('update', 'delete')
      .of('post')
      .when((w) => w.isOwner()),
  )
  .rule('block-banned', (r) =>
    r
      .deny()
      .on('*')
      .of('*')
      .when((w) => w.attr('status', 'eq', 'banned')),
  )
  .build()

Rules keep insertion order (pinned by the builder test "rule() preserves insertion order"). Order is the tie-breaker for first-match; see combining algorithms.

defineRule() - standalone rules

Use defineRule() to author rules once and attach them to several policies with addRule():

import { definePolicy, defineRule } from '@gentleduck/iam'

const ownerOnly = defineRule('owner-only')
  .allow()
  .on('update', 'delete')
  .of('post')
  .priority(20)
  .when((w) => w.isOwner())
  .build()

const maintenanceDeny = defineRule('maintenance-deny')
  .deny()
  .on('create', 'update', 'delete')
  .of('*')
  .priority(100)
  .when((w) => w.env('maintenanceMode', 'eq', true))
  .build()

const myPolicy = definePolicy('my-policy')
  .name('My Policy')
  .algorithm('highest-priority')
  .addRule(ownerOnly)
  .addRule(maintenanceDeny)
  .build()

RuleBuilder.build() validates the rule shape itself and throws, so a rule handed straight to an adapter fails where the bug was introduced. It also refuses a rule that configured nothing at all - the defaults are allow * on * unconditional, the broadest possible grant, so returning one silently is not an option. Cross-rule and policy-level checks still only run in PolicyBuilder.build(). See rules for every rule method.

when() - standalone condition groups

when() returns a bare When builder. Use it to build an AccessControl.IConditionGroup outside a rule, for example to reuse one group across rules via check()-free composition:

import { when } from '@gentleduck/iam'

const ownerOrAdmin = when()
  .or((o) => o.isOwner().role('admin'))
  .buildAll()
// { all: [ { any: [ ...ownerId eq $subject.id, subject.roles contains admin ] } ] }

The three terminal methods are buildAll() ({ all }), buildAny() ({ any }), and buildNone() ({ none }). Nothing chained yields an empty group: { all: [] } and { none: [] } match every request, { any: [] } matches none. See conditions and nesting.

Typed builders from createIam()

createIam() returns the same builders with TAction, TResource, TRole, TScope, and TContext fixed, so invalid actions, resources, and role IDs are compile errors:

import { createIam } from '@gentleduck/iam'

const access = createIam({
  actions: ['read', 'update'] as const,
  resources: ['post'] as const,
  roles: ['viewer', 'editor'] as const,
})

const p = access
  .definePolicy('typed')
  .rule('r', (r) => r.allow().on('read').of('post').when((w) => w.role('editor')))
  .build()
// access.defineRule('x').on('raed')  -> compile error

access.definePolicy, access.defineRule, access.when, and access.defineRole are the typed entry points. access.validatePolicy(input) runs the same validator build() uses. Full option table on access config.

Wildcards and patterns

Actions and resources accept '*' and suffix patterns. on() and of() are variadic.

r.on('*').of('*')            // all actions on all resources
r.on('*').of('post')         // all actions on posts
r.on('read').of('*')         // read on every resource
r.on('posts:*').of('org:*')  // colon-prefix patterns
r.on('read').of('dashboard.*') // dot-hierarchy pattern

Matching rules, from matchesAction / matchesResource in src/core/resolve/resolve.ts:

PatternMatchesDoes not match
'*'everything-
'post' (bare)exactly postpost.draft, post:draft, posts
'org:*'org:project, org:project:docorg, user
'dashboard.*'dashboard.users, dashboard.users.listdashboard, dashboard-x
'a.b.*'a.b.ca:b:c (separator must match)

A bare literal never matches sub-resources - to cover a subtree you must write the .* or :* suffix. The full algorithm, including why a dotted request or pattern routes through the hierarchical matcher, is on rule matching.

What build() validates

PolicyBuilder.build() runs validatePolicy() on the finished object and throws when any error-level issue is present (since 2.2.0). Warnings never throw. The flow:

Loading diagram...

The thrown message names the policy id and every failing code, path, and message:

[@gentleduck/iam:builder] PolicyBuilder.build("p") rejected by validator - UNREACHABLE_TARGET at "targets": Target admits "delete" but no allow rule covers it, so every request matching it is denied by this policy. Add a rule that allows it, or narrow the target.

[@gentleduck/iam:builder] PolicyBuilder.build("p") rejected by validator - ERR_REGEX_CATASTROPHIC at "rules[0].conditions.all[0].value": Condition "matches" pattern rejected: nested quantifier (e.g. `(a+)+`) - catastrophic backtracking risk

Errors a builder-built policy can hit:

CodeTrigger from the builder
MISSING_FIELDEmpty id; on() or of() called with zero arguments (actions/resources must be non-empty). Rule-level cases are caught by RuleBuilder.build() first
INVALID_TYPEpriority(NaN) / priority(Infinity); non-number version
UNREACHABLE_TARGETSince 5.4.0 an error: target() names an action/resource pair no allow rule covers, while the policy has at least one allow rule (5.3.0 made it a warning)
LIMIT_EXCEEDEDMore than 1000 rules, 100 actions or 100 resources per rule, an action x resource cartesian over 1000, a field over 256 chars, a string value over 1024 chars, or condition nesting deeper than 10
ERR_REGEX_CATASTROPHICA matches pattern longer than 128 chars, with nested quantifiers, alternation inside a quantified group, a backreference followed by a quantifier, a quantified group inside a lookaround, a {n,m} bound over 1000, more than 4 unbounded quantifiers, or unbounded quantifiers competing over overlapping atoms (^a+a+$, .*.*)
INVALID_ALGORITHM, INVALID_EFFECT, INVALID_OPERATOR, INVALID_CONDITION, INVALID_RULEOnly reachable when bypassing the typed API (for example check('x', 'like' as never, 1))

Warnings (returned by validatePolicy(), never thrown): DUPLICATE_RULE_ID, UNRESOLVABLE_FIELD (field root is not subject, resource, environment, action, or scope), UNRESOLVABLE_VALUE ($-value with an unresolvable root), BROAD_ALLOW (unconditional allow on */*). Full code reference: validation.

API reference

definePolicy

export const definePolicy: <
  TAction extends string = string,
  TResource extends string = string,
  TRole extends string = string,
  TScope extends string = string,
  TContext extends object = DotPath.IDefaultContext,
>(id: string) => PolicyBuilder<TAction, TResource, TRole, TScope, TContext>

PolicyBuilder

MethodSignatureEffect on the built policy
namename(n: string): thisname. Defaults to id
descdesc(d: string): thisdescription (optional)
versionversion(v: number): thisversion (optional, must be a number)
algorithmalgorithm(a: AccessControl.CombiningAlgorithm): thisalgorithm. Defaults to 'deny-overrides'
targettarget(t: NonNullable<AccessControl.IPolicy['targets']>): thistargets (actions?, resources?, roles?). Replaces any previous call
rulerule(id: string, fn: (r: RuleBuilder) => RuleBuilder): thisAppends fn(new RuleBuilder(id)).build() to rules
addRuleaddRule(rule: AccessControl.IRule): thisAppends a pre-built rule
buildbuild(): AccessControl.IPolicyValidates and returns the object, or throws (see above)

Built shape:

interface AccessControl.IPolicy<TAction, TResource, TRole> {
  readonly id: string
  readonly name: string
  readonly description?: string
  readonly version?: number
  readonly algorithm: AccessControl.CombiningAlgorithm
  readonly rules: readonly AccessControl.IRule<TAction, TResource>[]
  readonly targets?: {
    readonly actions?: readonly (TAction | '*')[]
    readonly resources?: readonly (TResource | '*')[]
    readonly roles?: readonly TRole[]
  }
}

defineRule

export const defineRule: <
  TAction extends string = string,
  TResource extends string = string,
  TScope extends string = string,
  TRole extends string = string,
  TContext extends object = DotPath.IDefaultContext,
>(id: string) => RuleBuilder<TAction, TResource, TScope, TRole, TContext>

Note the generic order differs from definePolicy (TScope before TRole). Methods are documented on rules.

when

export const when: <
  TAction extends string = string,
  TResource extends string = string,
  TScope extends string = string,
  TRole extends string = string,
  TContext extends object = DotPath.IDefaultContext,
  TActiveResource extends string = string,
>() => When<TAction, TResource, TRole, TScope, TContext, TActiveResource>

Methods are documented on conditions.

All three factories and the classes PolicyBuilder, RuleBuilder, When are exported from @gentleduck/iam, @gentleduck/iam/core, and @gentleduck/iam/core/builder.

Gotchas

  • build() throws, so a policy barrel that fails validation fails at import time. That is intentional: the message carries the policy id and the fix.
  • All three builders validate at build(). RoleBuilder's message omits the validator text (CODE at "path" only). See defining roles.
  • target() replaces the whole targets object; there is no merge across calls.
  • A when() that added no conditions does not count as configuring a rule, so defineRule('x').when((w) => w).build() is refused. defineRule('x').allow().when((w) => w).build() is accepted and is the same object as defineRule('x').allow().build() - a redundant spelling, not a hazard.

See also