Skip to main content

Combining algorithms

deny-overrides, allow-overrides, first-match, and highest-priority - a decision diagram and a worked example for each, plus the defaultEffect fallback

A policy's combining algorithm turns the set of rules that matched a request into one effect. It runs after targets have admitted the policy and after every rule's action, resource, and conditions have been evaluated - so its only input is the list of matched rules, in source order, and the engine's defaultEffect.

Where the algorithm sits

The combiner is the last step inside one policy. What each stage before it decides is on targets, rule matching, and conditions; what happens after is cross-policy combining.

Loading diagram...

M is the hand-off point: every algorithm sees the same list. DEF fires only when at least one rule's action/resource shape matched; otherwise the policy is NotApplicable and abstains entirely. It is one of two paths where a policy votes without a deciding rule - the other is a condition that threw, which makes the policy Indeterminate and casts a deny if it holds any deny rule, defaultEffect if it is allow-only.

Choosing one

AlgorithmDefault?PicksReads priority?Use for
deny-overridesyesFirst matched deny, else first matched allownoRestriction policies, guardrails, compliance layers
allow-overridesnoFirst matched allow, else first matched denynoPermissive grants; used by the generated RBAC policy
first-matchnoMatched rule with the largest priority; ties keep source orderyesOrdered, firewall-style rule lists
highest-prioritynoSame selection as first-matchyesTiered rules and emergency overrides

algorithm() defaults to 'deny-overrides' when you never call it. The type is AccessControl.CombiningAlgorithm:

type AccessControl.CombiningAlgorithm =
  | 'deny-overrides'
  | 'allow-overrides'
  | 'first-match'
  | 'highest-priority'

deny-overrides

Any matched deny wins, whatever else matched and whatever the priorities are. This is the default and the conservative choice.

Loading diagram...

Priority is never read at Q1 or Q2; the "first" in each branch is the earliest in the policy's rule array, and it only ever affects which rule id lands in IDecision.rule and reason, never the effect.

import { definePolicy } from '@gentleduck/iam'

const strict = definePolicy('strict-posts')
  .name('Strict Posts')
  .algorithm('deny-overrides')
  .rule('allow-read', (r) => r.allow().on('read').of('post'))
  .rule('deny-drafts', (r) =>
    r
      .deny()
      .on('read')
      .of('post')
      .when((w) => w.resourceAttr('status', 'eq', 'draft')),
  )
  .build()
RequestMatched rulesResult
read a published postallow-readallow, Allowed by rule "allow-read"
read a draft postallow-read, deny-draftsdeny, Denied by rule "deny-drafts"
update a postnone - no rule covers updateNotApplicable, policy abstains

The third row matters: the policy has no rule whose actions cover update, so it is skipped rather than voting defaultEffect. See targets.

allow-overrides

The mirror image: any matched allow wins. rolesToPolicy() builds the synthetic __rbac__ policy with this algorithm, because a role grant should never be cancelled by another role's absence.

Loading diagram...

const premium = definePolicy('premium-content')
  .name('Premium Content')
  .algorithm('allow-overrides')
  .rule('deny-by-default', (r) => r.deny().on('*').of('premium-content'))
  .rule('vip-access', (r) =>
    r
      .allow()
      .on('*')
      .of('premium-content')
      .when((w) => w.attr('tier', 'in', ['pro', 'enterprise'])),
  )
  .build()
RequestMatched rulesResult
Free-tier subject reads premium contentdeny-by-defaultdeny, Denied by rule "deny-by-default"
Pro-tier subject reads premium contentdeny-by-default, vip-accessallow, Allowed by rule "vip-access"

first-match

Named for firewall-style ordered lists, but it is priority-aware: the matched rule with the largest priority wins, and only a tie falls back to source order.

Loading diagram...

The strict > at CMP is what makes ties stable: an equal-priority rule declared later never displaces the earlier one.

const firewall = definePolicy('ip-firewall')
  .name('IP Firewall')
  .algorithm('first-match')
  .rule('block-known-bad', (r) =>
    r
      .deny()
      .on('*')
      .of('*')
      .priority(100)
      .when((w) => w.env('ip', 'in', ['10.0.0.99', '10.0.0.100'])),
  )
  .rule('allow-internal', (r) =>
    r
      .allow()
      .on('*')
      .of('*')
      .priority(50)
      .when((w) => w.env('ip', 'starts_with', '10.')),
  )
  .rule('deny-external', (r) => r.deny().on('*').of('*').priority(10))
  .build()
Request environment.ipMatched rulesWinnerResult
10.0.0.99all threeblock-known-bad (p=100)deny
10.0.0.7allow-internal, deny-externalallow-internal (p=50)allow
203.0.113.4deny-externaldeny-external (p=10)deny

With first-match you must give the rules explicit descending priorities for the list to read top-to-bottom the way a firewall does. Rules left at the default priority of 10 are all tied, and only then does declaration order decide.

highest-priority

highest-priority runs the same selection as first-match: scan the matched rules, keep the one with the largest priority, and let the earliest declared rule win a tie. They are one algorithm with two labels - both call the same topByPriority, so they cannot drift - and return the same effect and the same deciding rule for every input. Only the reason string differs (Highest priority: rule "x" (p=100) versus First match: rule "x" (allow)), which is what an operator reads in an audit log.

Loading diagram...

Choose highest-priority when you want the reader (and the explain trace) to understand the policy as a set of ranked tiers rather than an ordered list. Choose first-match when the rules are meant to be read as a sequence.

const tiered = definePolicy('classified-docs')
  .name('Classified Docs')
  .algorithm('highest-priority')
  .rule('normal-read', (r) => r.allow().on('read').of('document').priority(10))
  .rule('classified-deny', (r) =>
    r
      .deny()
      .on('read')
      .of('document')
      .priority(50)
      .when((w) => w.resourceAttr('classification', 'eq', 'top-secret')),
  )
  .rule('break-glass', (r) =>
    r
      .allow()
      .on('*')
      .of('*')
      .priority(100)
      .when((w) => w.role('incident-commander').env('breakGlass', 'eq', true)),
  )
  .build()
RequestMatched rulesWinnerResult
Anyone reads a normal documentnormal-readp=10allow
Anyone reads a top-secret documentnormal-read, classified-denyclassified-deny (p=50)deny
Incident commander with breakGlass reads itall threebreak-glass (p=100)allow

priority defaults to 10 and must be a finite number - priority(NaN) and priority(Infinity) are rejected by build() with INVALID_TYPE. A row that reached the store with a non-finite priority anyway is ranked as 0 rather than losing every comparison, so it still competes with default-priority rules instead of disappearing. Details on rules.

When no rule matched

If the policy was applicable (targets matched, and at least one rule's action/resource shape covered the request) but no rule's conditions held, every algorithm returns defaultEffect with no deciding rule:

{
  allowed: false,
  effect: 'deny',
  policy: 'strict-posts',
  reason: 'No matching rules. Defaulted to deny',
  duration: 0.04,
  timestamp: 1770000000000,
}

defaultEffect is an engine option, not a policy option; it defaults to 'deny'. Setting it to 'allow' requires allowFailOpen: true - the engine constructor refuses the combination otherwise, in both modes. See engine modes.

This is why a deny-only policy can deny more than you intended: a policy whose rules are all deny and none of which matched still votes deny through the fallback. The fixes, in order of preference:

Add a trailing catch-all allow

Under deny-overrides, r.allow().on('*').of('*') as the last rule turns the policy into "allow unless one of the deny rules fires". Denies still win, because deny-overrides ignores order.

Narrow the policy with targets

target({ actions: [...] }) makes the policy NotApplicable for everything else, so it abstains instead of voting. See targets.

Flip the engine default

defaultEffect: 'allow' with allowFailOpen: true treats every policy as a pure exception list. Only do this when something else is enforcing a baseline.

Since 5.4.0, PolicyBuilder.build() catches the most common version of this mistake at build time with UNREACHABLE_TARGET.

API reference

The algorithm is one field on the policy:

interface AccessControl.IPolicy<TAction, TResource, TRole> {
  // ...
  readonly algorithm: AccessControl.CombiningAlgorithm
}

// PolicyBuilder
algorithm(a: AccessControl.CombiningAlgorithm): this   // default: 'deny-overrides'

The decision a combiner produces:

interface AccessControl.IDecision {
  readonly allowed: boolean
  readonly effect: AccessControl.Effect         // 'allow' | 'deny'
  readonly rule?: AccessControl.IRule           // absent on a defaultEffect fallback
  readonly policy?: string
  readonly reason: string
  readonly duration: number                     // ms
  readonly timestamp: number                    // epoch ms
  readonly applicable?: boolean                 // false only for NotApplicable
}

Reason strings, by branch:

Branchreason
deny-overrides / allow-overrides picked a denyDenied by rule "<id>"
deny-overrides / allow-overrides picked an allowAllowed by rule "<id>"
first-match picked a ruleFirst match: rule "<id>" (<effect>)
highest-priority picked a ruleHighest priority: rule "<id>" (p=<priority>)
No rule matchedNo matching rules. Defaulted to <effect>
Targets missedPolicy "<id>" targets do not match. Not applicable.
No rule shape covered the requestPolicy "<id>" has no rule for this action/resource. Not applicable.

To run one policy's algorithm yourself - in a test, a migration check, or tooling - call the evaluator directly. Both are exported from @gentleduck/iam and @gentleduck/iam/core:

import { iamEvaluatePolicy, iamEvaluatePolicyFast } from '@gentleduck/iam'

const decision = iamEvaluatePolicy(strict, request, 'deny')
// AccessControl.IDecision, the development-mode shape

const verdict = iamEvaluatePolicyFast(strict, request, 'deny')
// true | false | null - null means NotApplicable, the production-mode shape

iamEvaluatePolicy takes (policy, request, defaultEffect?, caches?); defaultEffect defaults to 'deny'. The multi-policy entry points are iamEvaluate and iamEvaluateFast.

Gotchas

  • The algorithm only ranks rules within one policy. A policy that decides allow can still lose to another policy's deny under the default policyCombine: 'and'.
  • deny-overrides and allow-overrides ignore priority completely. Setting priorities on their rules changes nothing but the explain trace's ordering commentary.
  • first-match does not mean "first in the file" unless every matched rule shares a priority. Read it as "highest priority, then first".
  • A policy with first-match and no explicit priorities behaves exactly like the classic first-in-the-list semantics, because every rule sits at the default 10.
  • policyCombine: 'first-applicable' is refused by the engine constructor in production mode; the per-policy algorithm is unaffected by that restriction.

See also