Skip to content

Commit 9765af6

Browse files
authored
Merge pull request #1 from fystack/feat/allow-custom-actions
Allow custom actions
2 parents e43b0d1 + dec6ca9 commit 9765af6

File tree

4 files changed

+508
-26
lines changed

4 files changed

+508
-26
lines changed

README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,87 @@ default:
134134

135135
`Matched` is true when a specific rule (or a policy-level default) triggered. If all policies fall back to the document default, `Matched` remains false but `Effect` still reflects the decision (`ALLOW` or `DENY`).
136136

137+
### User-defined actions (custom effects)
138+
139+
The engine ships with two built-in effects (`ALLOW` and `DENY`) but accepts any non-empty string as an effect. `DENY` always short-circuits evaluation; every other effect is treated equally—first match wins. This lets you model domain-specific outcomes without forking or wrapping the engine:
140+
141+
```go
142+
package main
143+
144+
import (
145+
"context"
146+
"fmt"
147+
148+
"github.com/fystack/programmable-policy-engine/policy"
149+
)
150+
151+
// Define your own effects alongside the built-in ones.
152+
const (
153+
EffectAutoApprove policy.Effect = "AUTO_APPROVE"
154+
EffectFlagReview policy.Effect = "FLAG_FOR_REVIEW"
155+
)
156+
157+
func main() {
158+
defaultEffect := policy.EffectDeny
159+
160+
doc := policy.Document{
161+
DefaultEffect: &defaultEffect,
162+
Policies: []policy.Policy{
163+
{
164+
Name: "withdrawal-approval",
165+
Rules: []policy.Rule{
166+
{
167+
ID: "block_large",
168+
Effect: policy.EffectDeny,
169+
Condition: "ValueUSD > 50000",
170+
},
171+
{
172+
ID: "auto_approve_small_whitelisted",
173+
Effect: EffectAutoApprove,
174+
Condition: "IsWhitelisted && ValueUSD < 5000",
175+
},
176+
{
177+
ID: "flag_medium",
178+
Effect: EffectFlagReview,
179+
Condition: "ValueUSD >= 5000 && ValueUSD <= 50000",
180+
},
181+
{
182+
ID: "allow_whitelisted",
183+
Effect: policy.EffectAllow,
184+
Condition: "IsWhitelisted",
185+
},
186+
},
187+
},
188+
},
189+
}
190+
191+
engine, err := policy.CompileDocument(doc)
192+
if err != nil {
193+
panic(err)
194+
}
195+
196+
input := map[string]any{
197+
"ValueUSD": 3500.0,
198+
"IsWhitelisted": true,
199+
}
200+
201+
decision := engine.Evaluate(context.Background(), input)
202+
203+
switch decision.Effect {
204+
case policy.EffectDeny:
205+
fmt.Println("blocked:", decision.Message)
206+
case EffectAutoApprove:
207+
fmt.Println("auto-approved — no manual review needed")
208+
case EffectFlagReview:
209+
fmt.Println("flagged for manual review")
210+
case policy.EffectAllow:
211+
fmt.Println("allowed — requires standard approval")
212+
}
213+
}
214+
```
215+
216+
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.
217+
137218
### Mapping snake_case JSON to expr fields
138219

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

policy/effect.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,8 @@ const (
1010
EffectDeny Effect = "DENY"
1111
)
1212

13-
// IsValid returns true when the effect is one of the supported values.
14-
func (e Effect) IsValid() bool {
15-
switch e {
16-
case EffectAllow, EffectDeny:
17-
return true
18-
default:
19-
return false
20-
}
13+
// IsDeny returns true for the built-in deny effect.
14+
// Deny effects short-circuit evaluation (first deny wins).
15+
func (e Effect) IsDeny() bool {
16+
return e == EffectDeny
2117
}

policy/engine.go

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ func CompileDocument(doc Document, opts ...EngineOption) (*Engine, error) {
6969
engine.defaultEffect = *doc.DefaultEffect
7070
}
7171

72-
if !engine.defaultEffect.IsValid() {
73-
return nil, fmt.Errorf("invalid default effect %q", engine.defaultEffect)
72+
if engine.defaultEffect == "" {
73+
return nil, fmt.Errorf("default effect cannot be empty")
7474
}
7575

7676
compiled := make([]*compiledPolicy, 0, len(doc.Policies))
@@ -107,8 +107,8 @@ func (e *Engine) Evaluate(_ context.Context, input any) Decision {
107107
return decision
108108
}
109109

110-
var allowDecision Decision
111-
var hasAllowDecision bool
110+
var matchedDecision Decision
111+
var hasMatchedDecision bool
112112
evaluated := 0
113113

114114
policyErrors := make([][]error, len(e.policies))
@@ -140,15 +140,15 @@ func (e *Engine) Evaluate(_ context.Context, input any) Decision {
140140
}
141141
matchDecision.ErrorMessage = cleanErrorMessage(matchDecision.Error)
142142

143-
if rule.rule.Effect == EffectDeny {
143+
if rule.rule.Effect.IsDeny() {
144144
return matchDecision
145145
}
146146

147147
policyMatched = true
148148

149-
if !hasAllowDecision {
150-
allowDecision = matchDecision
151-
hasAllowDecision = true
149+
if !hasMatchedDecision {
150+
matchedDecision = matchDecision
151+
hasMatchedDecision = true
152152
}
153153
}
154154

@@ -163,19 +163,19 @@ func (e *Engine) Evaluate(_ context.Context, input any) Decision {
163163
}
164164
defaultDecision.ErrorMessage = cleanErrorMessage(defaultDecision.Error)
165165

166-
if policy.defaultEffect == EffectDeny {
166+
if policy.defaultEffect.IsDeny() {
167167
return defaultDecision
168168
}
169169

170-
if !hasAllowDecision {
171-
allowDecision = defaultDecision
172-
hasAllowDecision = true
170+
if !hasMatchedDecision {
171+
matchedDecision = defaultDecision
172+
hasMatchedDecision = true
173173
}
174174
}
175175
}
176176

177-
if hasAllowDecision {
178-
return allowDecision
177+
if hasMatchedDecision {
178+
return matchedDecision
179179
}
180180

181181
decision.Evaluated = evaluated
@@ -209,8 +209,8 @@ func compilePolicy(p Policy, cfg engineConfig) (*compiledPolicy, error) {
209209
hasLocalDefault = true
210210
}
211211

212-
if hasLocalDefault && !policyDefault.IsValid() {
213-
return nil, fmt.Errorf("policy %q has invalid default effect %q", p.Name, policyDefault)
212+
if hasLocalDefault && policyDefault == "" {
213+
return nil, fmt.Errorf("policy %q has empty default effect", p.Name)
214214
}
215215

216216
if len(p.Rules) == 0 && !hasLocalDefault {
@@ -243,8 +243,8 @@ func compilePolicy(p Policy, cfg engineConfig) (*compiledPolicy, error) {
243243

244244
p.Rules[idx] = rule
245245

246-
if !rule.Effect.IsValid() {
247-
return nil, fmt.Errorf("rule %q has invalid effect %q", rule.ID, rule.Effect)
246+
if rule.Effect == "" {
247+
return nil, fmt.Errorf("rule %q has empty effect", rule.ID)
248248
}
249249

250250
if rule.Condition == "" {

0 commit comments

Comments
 (0)