Skip to main content

Policy targets

Pre-filter a policy by action, resource, or role with target(); what NotApplicable means, how target patterns match, and the unreachable-target error

Targets scope a whole policy to specific actions, resources, or roles. A request that misses the targets never sees the policy's rules, and the policy contributes nothing to the final decision.

What a target miss means

A policy whose targets do not match the request is NotApplicable: the engine skips it in the cross-policy combine instead of counting it as a defaultEffect vote (since 2.0.0). Three worked cases:

Loading diagram...

SKIP1 and SKIP2 are skipped by every policyCombine mode; only EVAL runs the combining algorithm. The IDecision for a skipped policy carries applicable: false and the reason Policy "<id>" targets do not match. Not applicable.

Setting targets

import { definePolicy } from '@gentleduck/iam'

const adminPolicy = definePolicy('admin-only')
  .name('Admin-Only Policy')
  .target({ roles: ['admin', 'super-admin'] })
  .algorithm('deny-overrides')
  .rule('allow-admin-all', (r) => r.allow().on('*').of('*'))
  .build()
// Evaluated only for subjects whose effective roles include admin or super-admin.
const writePolicy = definePolicy('write-restrictions')
  .name('Write Restrictions')
  .target({
    actions: ['create', 'update', 'delete'],
    resources: ['post', 'comment'],
  })
  .algorithm('deny-overrides')
  .rule('business-hours', (r) =>
    r
      .deny()
      .on('*')
      .of('*')
      .when((w) => w.or((o) => o.env('hour', 'lt', 9).env('hour', 'gte', 17))),
  )
  .rule('allow-otherwise', (r) => r.allow().on('*').of('*'))
  .build()
// Applies only to writes on posts and comments; reads and other resources are untouched.

The allow-otherwise rule matters: without it, a write inside business hours would match the targets, match no rule, and fold defaultEffect (deny) into the decision. See "Target matched, no rule matched" below.

Target fields

FieldTypeMatch rule
actionsreadonly (TAction | '*')[]At least one entry matches request.action via the action matcher ('*', exact, or prefix:*)
resourcesreadonly (TResource | '*')[]At least one entry matches request.resource.type via matchesResource ('*', exact, prefix:*, prefix.*)
rolesreadonly TRole[]At least one entry is present in subject.roles (exact string membership, no wildcard)

Every field is optional; an omitted or empty field constrains nothing. Set fields combine with AND. target() replaces the whole object on each call.

How the applicability decision is made

The engine runs two checks before any rule condition is evaluated. Both can make the policy NotApplicable.

Loading diagram...

  • ACT, RES, ROLE are the target dimensions (policyApplies in src/core/evaluate/evaluate.libs.ts).
  • SHAPE is the rule-shape check: a policy about update has nothing to say about read even when its targets are silent, so it abstains instead of voting defaultEffect (evaluatePolicy in src/core/evaluate/evaluate.ts). The reason string is Policy "<id>" has no rule for this action/resource. Not applicable.
  • subject.roles at ROLE are the effective roles after inheritance and scoped-role enrichment - see scoped roles and evaluation pipeline.

Target patterns and hierarchy

targets.resources goes through the same matchesResource function as rule resources, so suffix wildcards work and bare literals stay literal. Pinned by the evaluate tests:

TargetRequest resourceApplicable?
'dashboard.*'dashboard.usersyes
'dashboard.*'dashboardno
'dashboard'dashboard.usersno
'org:*'org:projectyes

The only difference from rule resources is that a dotted request resource does not switch targets to the dot-only hierarchical matcher; both :* and .* are always honoured on targets. Details on rule matching.

Target matched, no rule matched

When targets match (or are absent), at least one rule has a matching action/resource shape, but no rule's conditions hold, the combining algorithm returns defaultEffect and the policy does vote. With the default defaultEffect: 'deny' and policyCombine: 'and', that vote is a deny.

This is why a target can widen what a policy refuses: adding a resource to target({ resources }) without an allow rule that covers it denies every caller for that resource. Since 5.4.0, PolicyBuilder.build() rejects that shape with UNREACHABLE_TARGET (an error; it was a warning in 5.3.0):

[@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.

The check fires only when the policy has at least one allow rule; a purely restrictive (deny-only) policy is exempt because denying everything it targets is the point. Only dimensions the target names are checked - an omitted dimension is unconstrained, so a target naming only impersonate is satisfied by an allow rule on .of('users'). Targets with more than 1000 action x resource pairs skip the check with a warning.

To keep a restriction policy from denying by default:

  • Add a catch-all allow rule (as allow-otherwise above) under deny-overrides, or a trailing allow under first-match.
  • Or set the engine defaultEffect: 'allow' and treat policies purely as exceptions (requires allowFailOpen in production mode; see engine modes).

When to use targets versus rule conditions

SituationUse
Policy applies to specific roles onlytarget({ roles })
Policy only matters for write operationstarget({ actions })
Policy is resource-type specifictarget({ resources })
Filtering depends on attributes or environmentRule conditions
Policy must abstain (not vote) for unrelated requestsTargets, or rules whose actions/resources do not cover the request

Targets are a fast pre-filter: the engine skips the whole policy before touching rules. In production mode the compiled table uses targets.actions / targets.resources at compile time to decide which cells a policy's rules belong in; see compiled table.

API reference

target(t: NonNullable<AccessControl.IPolicy<TAction, TResource, TRole>['targets']>): this

// AccessControl.IPolicy['targets']
readonly targets?: {
  readonly actions?: readonly (TAction | '*')[]
  readonly resources?: readonly (TResource | '*')[]
  readonly roles?: readonly TRole[]
}

IDecision.applicable is false for a NotApplicable policy decision and omitted otherwise.

Gotchas

  • roles is exact membership against subject.roles; '*' is not special there.
  • A policy with targets but zero rules is NotApplicable for everything: SHAPE has nothing to match. A policy with a '*'/'*' deny rule and matching targets denies everything the target admits.
  • In production mode policyCombine: 'first-applicable' is refused by the engine constructor; the other modes honour NotApplicable identically.

See also