unconditional-with-conditions
Summary: Rule defined unconditionally alongside conditional definitions
Category: Bugs
Automatically fixable: No
Avoid
package policy
allow := true if input.user.is_admin
# `allow` is now defined for every input, so the conditions above never decide
# anything. If `input.user.is_admin` is true, evaluation fails with a conflict.
allow := false
Prefer
package policy
default allow := false
allow := true if input.user.is_admin
Rationale
A definition written without if is defined for every input. When a rule has
one of those and other definitions that carry conditions, those conditions
cannot change the outcome, and only two things can happen:
- the values differ, and evaluation fails with
eval_conflict_error: complete rules must not produce multiple outputs; or - the values agree, and the conditional definitions are dead code.
Neither is what the author meant. Almost always the intent was a fallback, and
the fallback belongs in a default — which is a different construct, applying
only when no other definition produces a value, rather than competing with them.
The mistake is easy to make because a body-less definition reads like one. In
allow := false there is no if, nothing obviously conditional, and the line
looks like a declaration of a starting value rather than a third rule in the
set.
Static detection matters because of where the failure surfaces. The conflict is
raised at evaluation time, and only on the inputs that reach the conditional
branch, so a policy can pass opa check --strict, pass a test suite whose
fixtures never take that branch, and fail later on real data. In a policy used
as a gate, a rule that errors returns no decision at all, which at the boundary
is indistinguishable from a policy that stopped denying.
The same pattern also defeats default. Given
default f(_) := false
f(x) if x > 10
f(_) := false
the third definition is a complete definition, not a default, so f now has two
unconditional answers and the default is doing nothing.
Known limitations
-
Only definitions within one file are compared. A rule set split across files in the same package is not detected, since each file is linted on its own.
-
The unconditional definition must assign a constant value. A definition such as
allow := input.overridecan itself be undefined, and then the conditional definitions are genuinely doing work rather than being pointless:allow := input.overrideallow := false if not input.override -
Multi-value rules (
deny contains msg if ...) are not reported, because their definitions union rather than conflict. -
Function definitions whose arguments are not all distinct variables are not reported.
f("a") := 1has no body but applies only to the argument"a", so the other definitions in the set still do real work. This does leave a case uncovered:f("a") := 1besidef("a") := 2 if input.condcan conflict, and telling that apart needs the arguments compared rather than just counted. -
Rules whose head contains a variable (
config[k] := "loose") are not reported. Deciding whether two such definitions can ever collide means reasoning about what the variable is bound to, which needs more than this rule attempts. -
At least one definition must carry conditions. Two definitions that both lack them conflict without any condition being made pointless, and that is left to a rule of its own:
allow := trueallow := falseduplicate-ruledoes not report this either, as it compares rule text.
Configuration Options
This linter rule provides the following configuration options:
rules:
bugs:
unconditional-with-conditions:
# one of "error", "warning", "ignore"
level: error
Related Resources
- OPA Docs: Default Keyword
- OPA Docs: Complete Definitions
- GitHub: Source Code
Community
If you think you've found a problem with this rule or its documentation, would like to suggest improvements, new rules,
or just talk about Regal in general, please join us in the #regal channel in the Styra Community
Slack!