Skip to Content
AuthorizationWhy deny, then allow, then deny?

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

Why deny, then allow, then deny?

@evanion/acl checks the deny rules, then the allow rules, then the deny rules again, in seven fixed steps. access.can returns the Decision of the first step that applies, once the document is fresh and holds the key.

The seven steps acl resolves a permission in

A permission in @evanion/acl carries its deny rules in denyRules and its allow rules in rules. The rules within one side are OR-ed, so each side settles to one of four states:

A side’s stateWhen
matchedevery condition of one rule holds
definitely failsevery rule has a condition that is false, or the side has no rules
unusable clockno rule matched, and a rule with no false condition reads now from a clock that does not parse
unevaluableno rule matched or has an unusable clock, and a rule with no false condition reads an object.* field the object passed lacks

Seven steps check the two sides in order, and the first that applies decides:

  1. a deny rule matched → denied, naming the deny rule
  2. the allow side definitely fails → no-rule-matched
  3. the deny side has an unusable clock → unusable-clock, naming the deny rule
  4. the deny side is unevaluable → unevaluable, naming the deny rule, with both sides’ unread paths in missing
  5. an allow rule matched → allow, naming the allow rule
  6. the allow side has an unusable clock → unusable-clock, naming the allow rule
  7. the allow side is unevaluable → unevaluable, with the allow side’s unread paths in missing
The seven steps as one chain. The deny side is checked first and settled first, clock included; the single branch that reaches allow is the one where the deny side definitely fails and an allow rule matched.

Step 5 is the only step that answers allowed: true. Four calls on one Baize question reach steps 1, 2, 4 and 5:

import { } from '@evanion/acl'; type = { : string; : string }; const = <{ : string }, { : }>() .('question', () => .('update', .('object.status', 'locked')) .('locked-is-final') .('update', .('object.askedBy', 'subject.id')) .('asker-edits-own'), ) .(); const = { : 'customer-41' }; // Step 1: the deny matched, so the allow that also matched does not count. const = .(, 'question', 'update', { : 'customer-41', : 'locked', }); .; // -> 'denied' .; // -> 'locked-is-final' // Step 2: no allow rule can match, so the absent `status` changes nothing. const = .(, 'question', 'update', { : 'customer-92', }); .; // -> 'no-rule-matched' // Step 4: the allow rule matched and the deny side could not be read. const = .(, 'question', 'update', { : 'customer-41', }); .; // -> 'unevaluable' .; // -> 'locked-is-final' .; // -> ['object.status'] // Step 5: the deny side definitely failed, so the allow grants. const = .(, 'question', 'update', { : 'customer-41', : 'draft', }); .; // -> true .; // -> 'allow' .; // -> 'asker-edits-own'

.id() names the rule declared before it, and rule returns that name. can takes the object as Partial<Question>, so a call may leave status out. The builder writes every deny() to denyRules and every allow() to rules, so the order a policy declares the two sides in never reaches the document.

Two reasons come before step 1. stale-contract answers when more than maxStale has passed since the holder’s fetchedAt, and unknown-action answers when an Access built with closed: true does not hold the key.

Why acl checks the deny side before and after the allow side

@evanion/acl checks the deny side’s result at two points: at step 1 for a deny rule that matched, and at steps 3 and 4 for a deny rule it could not read. Step 2 (the allow side definitely fails) sits between those points. A caller settles an unevaluable answer by fetching the fields missing names and asking again, and no fetch turns a definite no-allow into a grant. An unevaluable answer at step 2 would send a UI to fetch and ask again forever.

Steps 3 and 4 come before step 5 (an allow rule matched), because a deny rule the engine could not read might have matched. With the deny side undecided, the allow side settles the answer this way:

The deny sideThe allow sideThe answerStep
unusable clockmatchedunusable-clock3
unevaluablematchedunevaluable4
unusable clockdefinitely failsno-rule-matched2
unevaluabledefinitely failsno-rule-matched2

A Baize order rule that closes ordering during a stocktake shows steps 1, 3 and 2 on the clock. p.after('now', …) and p.before('now', …) read the instant passed as can’s fifth argument, and 'soon' does not parse as one. The third type argument to policy names place as the order’s action:

import { } from '@evanion/acl'; type = { : string; : string[] }; const = <, { : { : string } }, { : 'place' }>() .('order', () => .('place', .('subject.roles', 'customer')) .('customers-order') .( 'place', .('now', '2026-12-27T00:00:00Z'), .('now', '2026-12-29T00:00:00Z'), ) .('stocktake-closes-orders'), ) .(); const = { : 'customer-41', : ['customer'] }; const = { : 'visitor-7', : [] }; // Step 1: the stocktake window is open, so the deny rule matched. const = '2026-12-28T10:00:00Z'; .(, 'order', 'place', , ).; // -> 'denied' // Step 3: the allow rule matched, and the deny rule's clock does not parse. const = .(, 'order', 'place', , 'soon'); .; // -> 'unusable-clock' .; // -> 'stocktake-closes-orders' // Step 2 comes first: no allow rule matches the visitor, clock or no clock. .(, 'order', 'place', , 'soon').; // -> 'no-rule-matched'

Why rule order never changes a decision

Some engines treat rule order as part of the policy by letting the last matching declaration decide. In that approach, the engine scans rules from the last declared to the first and returns the first matching rule, whether it allows or forbids.

A matrix is the frozen JSON document one service publishes and another service adopts. Three things break there under order-dependence:

  • Identical rules in a different order decide differently, so a publishing service that reorders its own output changes what a consumer grants.
  • applyDenyOverlay appends another team’s deny rules to a permission’s denyRules. Under last-declaration-wins, the veto would depend on where the append put them.
  • A reviewer could not say what a matrix grants without reading every rule in sequence, and matrix review is the only control over an over-broad grant.

@evanion/acl answers the same allowed and reason for any order of rules or denyRules. Rule order changes two fields: rule names the earliest rule that settled its side, and missing lists paths in the order the rules read them.

How deny-overrides compares with other rule-combining approaches

@evanion/acl runs deny-overrides and exposes no setting for it. CASL, the eXtensible Access Control Markup Language (XACML) 3.0, AWS Identity and Access Management (IAM) and Casbin combine rules in other ways, and three of them let a caller change the choice. The table is a reference for porting rules:

EngineHow it combinesWhere that choice livesWhat it answers
CASLthe last matching declaration, can or cannotthe code that built the abilitya boolean
XACML 3.0one of the named algorithms, deny-overrides among themthe policy, per policy setPermit, Deny, NotApplicable, Indeterminate
AWS IAMdeny-overrides, over an implicit denyfixed by the servicea boolean
Casbinwhatever [policy_effect] namesthe model file, per deploymenta boolean
acldeny-overrides, and nothing configures itnowhere: the seven steps above are the engineallow, a refusal, or unevaluable

Where the algorithm is configurable, a consumer adopting a foreign policy inherits whichever one the publisher picked. Three engines write the algorithm down:

  • XACML 3.0. Appendix C of the standard names the combining algorithms: deny-overrides, permit-overrides, first-applicable and only-one-applicable, alongside ordered and deny-unless-permit variants. XACML states deny-overrides as “if a single <Rule> or <Policy> element is encountered that evaluates to ‘Deny’, then, regardless of the evaluation result of the other … elements in the applicable policy, the combined result is ‘Deny’”.
  • AWS IAM. “By default, all requests are implicitly denied”, a policy must explicitly allow them, and “An explicit deny overrides an explicit allow.” Those are acl’s step 1 (a deny rule matched), step 2 (no allow rule matched) and step 5 (an allow rule matched), with no undecided steps.
  • Casbin. The [policy_effect] section takes allow-override, deny-override, the conjunction of the two, or priority(p.eft) || deny, which is first-applicable.

acl’s unevaluable beside XACML’s Indeterminate

unevaluable is the third answer @evanion/acl gives: allowed is false, and missing names the object paths the engine could not read, so a caller can fetch them and ask again. Step 4 (the deny side is unevaluable) and step 7 (the allow side is unevaluable) produce it. Where a boolean answer would hide the difference between “no” and “not enough data to say”, acl returns this third answer and names the paths to fetch.

XACML’s Indeterminate is the closest standard concept. Its decision values are Permit, Deny, NotApplicable and Indeterminate, and its extended Indeterminate{D}, {P} and {DP} variants travel through the combining algorithms. § 7.3.5 says that where a required attribute is not found “the result is Indeterminate, and a MissingAttributeDetail element SHOULD be included in the Status”.

Why a fixed order lets every acl layer re-decide

The seven steps of @evanion/acl read the permission, the subject, the object and now, and no array order or per-process setting. A browser, the server behind it and a downstream service holding the same document, subject, object and now reach the same decision. A layer that re-decides therefore confirms the answer the layer before it gave. Every app in the chain evaluates for itself and trusts no earlier layer, and the security contract argues why.

Two inputs differ between layers unless the caller aligns them:

  • can reads each machine’s wall clock when the caller omits now, so two layers deciding a p.before or p.after rule near its boundary can disagree. Pass the same now to every layer that has to agree.
  • fetchedAt, maxStale and closed are options each holder passes to hydratePolicy, and they act before step 1. A holder built with different values can answer stale-contract or unknown-action where another reaches the seven steps.

Where to go next

Last updated on