Building policies
WIPdefinePolicy(), 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.
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:
| Pattern | Matches | Does not match |
|---|---|---|
'*' | everything | - |
'post' (bare) | exactly post | post.draft, post:draft, posts |
'org:*' | org:project, org:project:doc | org, user |
'dashboard.*' | dashboard.users, dashboard.users.list | dashboard, dashboard-x |
'a.b.*' | a.b.c | a: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:
build() throws the coded IamError IAM_VALIDATION_FAILED (meta: { kind: 'policy' | 'role' | 'rule' | 'request', issues: string[] }); every builder's .build() shares this one throw path (throwIamValidationFailed in core/errors/errors.validation.ts). Each entry in meta.issues is one failing issue formatted as `${code}${path ? ` at "${path}"` : ''}: ${message}`:
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.
ERR_REGEX_CATASTROPHIC at "rules[0].conditions.all[0].value": Condition "matches" pattern rejected: nested quantifier (e.g. `(a+)+`) - catastrophic backtracking risk
The IamError's own .message is just the bare code IAM_VALIDATION_FAILED - read meta.kind and meta.issues for the detail, never the message.
Errors a builder-built policy can hit:
| Code | Trigger from the builder |
|---|---|
MISSING_FIELD | Empty id; on() or of() called with zero arguments (actions/resources must be non-empty). Rule-level cases are caught by RuleBuilder.build() first |
INVALID_TYPE | priority(NaN) / priority(Infinity); non-number version |
UNREACHABLE_TARGET | Since 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_EXCEEDED | More 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_CATASTROPHIC | A 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_RULE | Only 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
| Method | Signature | Effect on the built policy |
|---|---|---|
name | name(n: string): this | name. Defaults to id |
desc | desc(d: string): this | description (optional) |
version | version(v: number): this | version (optional, must be a number) |
algorithm | algorithm(a: AccessControl.CombiningAlgorithm): this | algorithm. Defaults to 'deny-overrides' |
target | target(t: NonNullable<AccessControl.IPolicy['targets']>): this | targets (actions?, resources?, roles?). Replaces any previous call |
rule | rule(id: string, fn: (r: RuleBuilder) => RuleBuilder): this | Appends fn(new RuleBuilder(id)).build() to rules |
addRule | addRule(rule: AccessControl.IRule): this | Appends a pre-built rule |
build | build(): AccessControl.IPolicy | Validates 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, but the policy's ownidis not part ofIAM_VALIDATION_FAILED'smeta- onlykindand the formattedissueslist are. The caller already knows which policy failed, since they calledbuild()on it.- All three builders (
PolicyBuilder,RuleBuilder,RoleBuilder) validate atbuild()and throw through the identicalIAM_VALIDATION_FAILEDpath (throwIamValidationFailed) with the identical${code} at "path": messageformatting - there is no longer a format difference between them. 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, sodefineRule('x').when((w) => w).build()is refused.defineRule('x').allow().when((w) => w).build()is accepted and is the same object asdefineRule('x').allow().build()- a redundant spelling, not a hazard.
See also
- Rules - every
RuleBuildermethod and default - Conditions - every
Whenmethod and operator - Rule matching - the matcher the patterns above go through
- Validation - every validator code