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

Why deny, then allow, then deny?

Not on npm yet

npm install @evanion/acl does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.

The concept. decideResolved settles one permission in seven steps that run in a fixed order.

What you get. The seven steps written out, and which of the four outcomes each one produces.

Why you want it. No option reorders them, so a rule you read in one service decides the same way in every other.

How the library gets you there. decideResolved reads permission.denyRules and permission.rules, the deny side first.

The order acl resolves a permission in

The deny side is permission.denyRules and the allow side is permission.rules. Rules within a side are OR-ed, so one match settles that side, and any number of rules can match at once. Four outcomes cover most calls:

Deny sideAllow sideDecision
matchesanythingdenied
failsmatchesallow
failsfailsno-rule-matched
unevaluablenot failingunevaluable, naming both sides

libs/acl/src/evaluate.ts defines the full order, and Decisions has the shape each step hands back:

  1. a deny rule matches → denied
  2. the allow side definitely fails → no-rule-matched
  3. the deny side reads an unusable clock → unusable-clock, naming the deny rule
  4. the deny side is unevaluable → unevaluable, naming the deny rule
  5. an allow rule matches → allow
  6. the allow side reads an unusable clock → unusable-clock, naming the allow rule
  7. the allow side is unevaluable → unevaluable
The seven steps as one chain. The deny side is read 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.

Why acl reads the deny side twice

Step 2 answers no-rule-matched ahead of the deny side’s undecided cases, because no fetch repairs a definite “no allow rule matched”.

Steps 3 and 4 sit above step 5, so a deny side held up by an unreadable path or by a now that does not parse reports itself and hands back no allow. An undecided deny turns an allow into an unevaluable and leaves a definite no-allow alone. Every branch below step 1 answers allowed: false, so no undecided path leaks a grant.

Why acl declines last-declaration-wins

CASL resolves the other way. _indexAndAnalyzeRules walks the raw rules backwards so the last declaration is scanned first, and relevantRuleFor returns the first conditional match, inverted or not. CASL’s guide names the hazard: “the order of rules matters: cannot declarations should follow after can, otherwise they will be overridden by can”.

A matrix is a frozen JSON document one service emits and another adopts with parseMatrix. Three things break there under order-dependence:

  • Identical rules in a different order decide differently, so a producer that reorders its own output changes what a consumer grants.
  • federatedPolicies would resolve two origins by the order their documents were concatenated in.
  • 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.

Under step 1 a deny wins wherever it sits in the array, so array order carries no meaning.

Deny-overrides, and the names XACML gave it

acl runs deny-overrides and exposes no setting. Four other engines put that choice where a caller can change it:

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

A consumer adopting a foreign matrix inherits whichever algorithm the producer picked. Three of those engines write the algorithm down:

  • XACML 3.0. OASIS named the combining algorithms in Appendix C of the eXtensible Access Control Markup Language (XACML) 3.0: 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 steps 1, 2 and 5 with the undecided steps taken out.
  • Casbin. The [policy_effect] section takes allow-override, deny-override, the conjunction of the two, or priority(p.eft) || deny, which is first-applicable.

The third state, and what XACML calls Indeterminate

unevaluable is acl’s third answer: the engine could not reach a decision with the data it was handed, and missing names the paths to fetch. Steps 3 and 4 separate that case from a refusal, and CASL, AWS IAM and Casbin all answer a boolean.

XACML is the exception. 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”. Nobody checked for this page whether a deployed implementation returns those details.

Why a fixed order is what lets every layer re-decide

acl in a browser toggles what a user sees and enforces nothing. A trusted environment makes the decision that counts, and every app in the chain evaluates for itself and trusts no earlier layer, which the security contract argues in full.

Every step above reads the permission and the context, and no array order, construction option or per-process setting. A browser, the server behind it and a downstream service holding the same document and subject reach the same decision, so a layer that re-decides confirms the answer it was handed.

Where to go next

Last updated on