Skip to content

Commit f66e04b

Browse files
committed
Add WithEffectPrecedence for deny-overrides evaluation
Introduces an optional effect precedence strategy that resolves conflicts by highest-priority effect instead of first-match-wins. This prevents misconfigured rule ordering from letting an ALLOW slip past a DENY or REVIEW in custody contexts.
1 parent 9765af6 commit f66e04b

File tree

3 files changed

+488
-6
lines changed

3 files changed

+488
-6
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Key use cases include:
3737
## Features
3838
- **Deterministic evaluations** powered by compiled expr programs
3939
- **Default effects** at document and policy level with a DENY-overrides-ALLOW model
40+
- **Effect precedence** (optional) for deny-overrides evaluation — highest-priority effect wins regardless of rule order
4041
- **Strict schema validation** (optional) via `WithSchemaDefinition`
4142
- **Friendly error reporting** that surfaces the failing rule and expression issue
4243
- **Zero-config loader** for JSON policy documents
@@ -215,6 +216,46 @@ func main() {
215216

216217
Because the caller defines what each effect means, you can extend the decision space (e.g., `RATE_LIMIT`, `QUARANTINE`, `NOTIFY_COMPLIANCE`) without changing the engine.
217218

219+
### Effect precedence (deny-overrides)
220+
221+
By default the engine uses first-match-wins for non-deny effects. This works well when rule ordering is deliberate, but in custody and treasury contexts a priority mistake can accidentally let an `ALLOW` slip in before a `DENY` or `REVIEW`.
222+
223+
`WithEffectPrecedence` switches to a **highest-priority-wins** strategy. You declare the hierarchy once at compile time and the engine enforces it regardless of rule or policy ordering:
224+
225+
```go
226+
const EffectReview policy.Effect = "REVIEW"
227+
228+
engine, err := policy.CompileDocument(doc,
229+
policy.WithEffectPrecedence(policy.EffectDeny, EffectReview, policy.EffectAllow),
230+
)
231+
```
232+
233+
With this configuration:
234+
235+
| Matched effects | Result | Why |
236+
| --- | --- | --- |
237+
| `DENY` + `ALLOW` | `DENY` | DENY has higher precedence |
238+
| `REVIEW` + `ALLOW` | `REVIEW` | REVIEW has higher precedence than ALLOW |
239+
| `ALLOW` only | `ALLOW` | Single match, returned as-is |
240+
| Nothing matched | `DENY` | Document default kicks in |
241+
242+
The engine still short-circuits when the highest-priority effect (first in the list) is matched — there is no point evaluating further once a `DENY` is found. All other matches are collected and the winner is selected at the end, so `Evaluated` on the decision always reflects the full rule count.
243+
244+
Effects not listed in the precedence order are treated as lowest priority. They can still win when they are the only match, but any effect that *is* in the list will beat them.
245+
246+
```go
247+
decision := engine.Evaluate(context.Background(), input)
248+
249+
switch decision.Effect {
250+
case policy.EffectDeny:
251+
fmt.Println("blocked:", decision.Message)
252+
case EffectReview:
253+
fmt.Println("flagged for manual review")
254+
case policy.EffectAllow:
255+
fmt.Println("approved")
256+
}
257+
```
258+
218259
### Mapping snake_case JSON to expr fields
219260

220261
Use the `expr` struct tag to expose snake_case JSON fields with the same name inside expressions:

policy/engine.go

Lines changed: 157 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,20 @@ import (
1212

1313
// Engine evaluates compiled policies against an input context.
1414
type Engine struct {
15-
defaultEffect Effect
16-
policies []*compiledPolicy
15+
defaultEffect Effect
16+
policies []*compiledPolicy
17+
effectPrecedence map[Effect]int // lower index = higher priority
1718
}
1819

1920
// EngineOption configures compilation behaviour.
2021
type EngineOption func(*engineConfig)
2122

2223
type engineConfig struct {
23-
exprOptions []expr.Option
24-
defaultEffect Effect
25-
env any
26-
strictTypes bool
24+
exprOptions []expr.Option
25+
defaultEffect Effect
26+
env any
27+
strictTypes bool
28+
effectPrecedence []Effect
2729
}
2830

2931
// WithExprOptions passes expr compilation options for every rule.
@@ -51,6 +53,19 @@ func WithDefaultEffect(effect Effect) EngineOption {
5153
}
5254
}
5355

56+
// WithEffectPrecedence defines the priority order of effects for conflict resolution.
57+
// Effects listed first have higher priority. When multiple rules match, the effect
58+
// with the highest priority wins regardless of rule or policy ordering.
59+
// Any matched effect not in the list is treated as lowest priority (first-match-wins among them).
60+
//
61+
// Example for custody: WithEffectPrecedence(EffectDeny, EffectReview, EffectAllow)
62+
// This means DENY beats REVIEW beats ALLOW, regardless of rule order.
63+
func WithEffectPrecedence(effects ...Effect) EngineOption {
64+
return func(cfg *engineConfig) {
65+
cfg.effectPrecedence = effects
66+
}
67+
}
68+
5469
// CompileDocument converts a policy document into an executable engine.
5570
func CompileDocument(doc Document, opts ...EngineOption) (*Engine, error) {
5671
cfg := engineConfig{
@@ -65,6 +80,13 @@ func CompileDocument(doc Document, opts ...EngineOption) (*Engine, error) {
6580
defaultEffect: cfg.defaultEffect,
6681
}
6782

83+
if len(cfg.effectPrecedence) > 0 {
84+
engine.effectPrecedence = make(map[Effect]int, len(cfg.effectPrecedence))
85+
for i, e := range cfg.effectPrecedence {
86+
engine.effectPrecedence[e] = i
87+
}
88+
}
89+
6890
if doc.DefaultEffect != nil {
6991
engine.defaultEffect = *doc.DefaultEffect
7092
}
@@ -96,6 +118,10 @@ func CompilePolicies(policies []Policy, opts ...EngineOption) (*Engine, error) {
96118

97119
// Evaluate runs all compiled policies against the provided context value.
98120
// The final decision honours DENY over ALLOW, with a configurable default fallback.
121+
//
122+
// When WithEffectPrecedence is configured, all matching rules are evaluated and
123+
// the decision with the highest-priority effect wins. Otherwise, DENY short-circuits
124+
// and the first non-deny match wins.
99125
func (e *Engine) Evaluate(_ context.Context, input any) Decision {
100126
decision := Decision{
101127
Effect: e.defaultEffect,
@@ -107,6 +133,15 @@ func (e *Engine) Evaluate(_ context.Context, input any) Decision {
107133
return decision
108134
}
109135

136+
if e.effectPrecedence != nil {
137+
return e.evaluateWithPrecedence(input, decision)
138+
}
139+
return e.evaluateFirstMatch(input, decision)
140+
}
141+
142+
// evaluateFirstMatch is the default evaluation strategy: DENY short-circuits,
143+
// first non-deny match wins.
144+
func (e *Engine) evaluateFirstMatch(input any, decision Decision) Decision {
110145
var matchedDecision Decision
111146
var hasMatchedDecision bool
112147
evaluated := 0
@@ -184,6 +219,122 @@ func (e *Engine) Evaluate(_ context.Context, input any) Decision {
184219
return decision
185220
}
186221

222+
// matchSource records where a winning match came from so we can build
223+
// the Decision once after all rules have been evaluated.
224+
type matchSource struct {
225+
effect Effect
226+
policyName string
227+
ruleID string
228+
message string
229+
isDefault bool // true when the match came from a policy default
230+
}
231+
232+
// evaluateWithPrecedence evaluates all rules and returns the decision with the
233+
// highest-priority effect according to the configured precedence order.
234+
// The Decision is built once at the end so that Evaluated and Error reflect
235+
// the full evaluation run.
236+
func (e *Engine) evaluateWithPrecedence(input any, decision Decision) Decision {
237+
var best *matchSource
238+
bestPriority := -1 // -1 = no known priority
239+
evaluated := 0
240+
241+
policyErrors := make([][]error, len(e.policies))
242+
243+
for idx, pol := range e.policies {
244+
policyMatched := false
245+
246+
for _, rule := range pol.rules {
247+
evaluated++
248+
249+
ok, err := rule.evaluate(input)
250+
if err != nil {
251+
policyErrors[idx] = append(policyErrors[idx], err)
252+
continue
253+
}
254+
255+
if !ok {
256+
continue
257+
}
258+
259+
policyMatched = true
260+
261+
priority, known := e.effectPrecedence[rule.rule.Effect]
262+
if best == nil {
263+
best = &matchSource{
264+
effect: rule.rule.Effect,
265+
policyName: pol.policy.Name,
266+
ruleID: rule.rule.ID,
267+
message: rule.rule.Description,
268+
}
269+
if known {
270+
bestPriority = priority
271+
}
272+
} else if known && (bestPriority == -1 || priority < bestPriority) {
273+
best.effect = rule.rule.Effect
274+
best.policyName = pol.policy.Name
275+
best.ruleID = rule.rule.ID
276+
best.message = rule.rule.Description
277+
best.isDefault = false
278+
bestPriority = priority
279+
}
280+
281+
// Short-circuit: matched the highest-priority effect
282+
if known && priority == 0 {
283+
return e.buildDecision(best, evaluated, policyErrors)
284+
}
285+
}
286+
287+
if !policyMatched && pol.hasLocalDefault {
288+
priority, known := e.effectPrecedence[pol.defaultEffect]
289+
if best == nil {
290+
best = &matchSource{
291+
effect: pol.defaultEffect,
292+
policyName: pol.policy.Name,
293+
message: "policy default effect applied",
294+
isDefault: true,
295+
}
296+
if known {
297+
bestPriority = priority
298+
}
299+
} else if known && (bestPriority == -1 || priority < bestPriority) {
300+
best.effect = pol.defaultEffect
301+
best.policyName = pol.policy.Name
302+
best.ruleID = ""
303+
best.message = "policy default effect applied"
304+
best.isDefault = true
305+
bestPriority = priority
306+
}
307+
308+
if known && priority == 0 {
309+
return e.buildDecision(best, evaluated, policyErrors)
310+
}
311+
}
312+
}
313+
314+
if best != nil {
315+
return e.buildDecision(best, evaluated, policyErrors)
316+
}
317+
318+
decision.Evaluated = evaluated
319+
decision.Error = joinErrors(flattenErrors(policyErrors))
320+
decision.ErrorMessage = cleanErrorMessage(decision.Error)
321+
return decision
322+
}
323+
324+
func (e *Engine) buildDecision(src *matchSource, evaluated int, policyErrors [][]error) Decision {
325+
allErrors := joinErrors(flattenErrors(policyErrors))
326+
return Decision{
327+
Effect: src.effect,
328+
Policy: src.policyName,
329+
Rule: src.ruleID,
330+
Message: src.message,
331+
Matched: true,
332+
Evaluated: evaluated,
333+
Error: allErrors,
334+
ErrorMessage: cleanErrorMessage(allErrors),
335+
}
336+
}
337+
187338
type compiledPolicy struct {
188339
policy Policy
189340
rules []*compiledRule

0 commit comments

Comments
 (0)