Repository: cerbos/cerbos (go)

Issue:
Block condition operators not evaluating correctly in REPL
### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

Hi!
I ran into an issue a couple days ago, where one of my policies using nested all-any-all operators does not evaluate correctly.

I have the following policy for my `Permission`-Resource:
```yaml
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: default
  resource: Permission
  variables:
    import: [claim_variables]
  rules:
    - actions: [create]
      effect: EFFECT_ALLOW
      roles: [USER, ADMIN]
      condition:
        match:
          all:
            of:
              - expr: V.claim_permission_administration # -> "CLAIM_PERMISSION_ADMINISTRATION" in P.attr.claims
              - expr: R.attr.externalId == "null"
              - any:
                  of:
                   - expr: V.claim_long_term # -> "CLAIM_LONG_TERM_ADMINISTRATION" in P.attr.claims
                   - all:
                       of:
                         - expr: R.attr.beginDate != "*"
                         - expr: R.attr.endDate != "*"
                         - expr: timestamp(R.attr.endDate) - timestamp(R.attr.beginDate) <= duration("%dh".format([int(P.attr.guiDefaults.maxPermissionInterval) * 24]))
```

This policy is not evaluated correctly. If either the `V.claim_long_term` condition is false, or the nested `all` is false, the `any` is also false even though it should be true (since only one of the cases is false) and therefore, the entire policy fails.

### Expected Behavior

The [documentation](https://docs.cerbos.dev/cerbos/latest/policies/conditions.html#_expressions_and_blocks) states, that operators can be nested. Therefore, I would expect this policy to evaluate the `any` correctly, based on the nested conditions.

At the moment, we can work around this by removing the `any`-condition entirely and putting the conditions into a single logical expression like this:
```yaml
- expr: V.claim_long_term || (R.attr.beginDate != "*" && R.attr.endDate != "*" && timestamp(R.attr.endDate) - timestamp(R.attr.beginDate) <= duration("%dh".format([int(P.attr.guiDefaults.maxPermissionInterval) * 24])))
```

But adding all these conditions into one expression makes it very hard to read what is actually happening. We also have another policy that is an extension of this one and has a more complex `all` case, that is even harder to read when putting it all into one single logical expression.

### Steps To Reproduce

I'll add examples with the result from `repl`:

**EXAMPLE 1:**

Resource:
```json
{
  "kind": "Permission",
  "id": "0671c852-45e3-4814-a0db-69fd3bc83995",
  "attr": {
    "beginDate": "*",
    "endDate": "*",
    "externalId": "null"
  }
}
```

Principal:
```json
{
  "id": "0612ecc1-8057-455e-821f-140a6c6b0842",
  "roles": [
    "ADMIN"
  ],
  "attr": {
    "claims": [
      "CLAIM_PERMISSION_ADMINISTRATION"
      "CLAIM_LONG_TERM_ADMINISTRATION"
    ],
    "guiDefaults": {
      "maxPermissionInterval": 7
    }
  }
}
```

When I run this, I get the following result from `repl`:
<img width="1070" alt="Image" src="https://github.com/user-attachments/assets/2cfacab4-8290-46aa-b290-24155ca31f58" />

**EXAMPLE 2:**

Resource:
```json
{
  "kind": "Permission",
  "id": "6ddc1faa-851d-4ed7-908f-84b175fdcdaa",
  "attr": {
    "beginDate": "2025-03-13T00:00:00.000Z",
    "endDate": "2025-03-13T00:00:00.000Z",
    "externalId": "null"
  }
}
```

Principal:
```json
{
  "id": "0612ecc1-8057-455e-821f-140a6c6b0842",
  "roles": [
    "ADMIN"
  ],
  "attr": {
    "claims": [
      "CLAIM_PERMISSION_ADMINISTRATION"
    ],
    "guiDefaults": {
      "maxPermissionInterval": 7
    }
  }
}
```

When I run this, I get the following result from `repl`:

<img width="1065" alt="Image" src="https://github.com/user-attachments/assets/0ff11204-2c71-43bc-a497-034ec7243023" />

### Environment

```markdown
- OS: MacOS Sonoma 14.3
- Cerbos version: 0.41.0
- Docker version: v4.39.0
```

### Anything else?

_No response_

Pull request:
fix: Evaluate condition blocks correctly in REPL
Fixes #2510 

Patch:
diff --git a/api/genpb/go.mod b/api/genpb/go.mod
index 0b2a6e22..c77a82de 100644
--- a/api/genpb/go.mod
+++ b/api/genpb/go.mod
@@ -15,7 +15,7 @@ require (
 )
 
 require (
-	golang.org/x/net v0.35.0 // indirect
+	golang.org/x/net v0.36.0 // indirect
 	golang.org/x/sys v0.30.0 // indirect
 	golang.org/x/text v0.22.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect
diff --git a/api/genpb/go.sum b/api/genpb/go.sum
index ab6c50b5..2d65fe9c 100644
--- a/api/genpb/go.sum
+++ b/api/genpb/go.sum
@@ -28,8 +28,8 @@ go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce
 go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
 go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
 go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
-golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
-golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
+golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
 golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
 golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
diff --git a/cmd/cerbos/repl/internal/eval.go b/cmd/cerbos/repl/internal/eval.go
index f8764e29..d98e6357 100644
--- a/cmd/cerbos/repl/internal/eval.go
+++ b/cmd/cerbos/repl/internal/eval.go
@@ -73,10 +73,23 @@ type eval struct {
 }
 
 func (e *eval) append(eval *eval) {
-	if eval.err != nil || !eval.success {
+	e.evals = append(e.evals, eval)
+
+	if eval.err != nil {
 		e.err = errEvalErrorPresent
 		e.success = false
 	}
 
-	e.evals = append(e.evals, eval)
+	if e.err == nil {
+		switch e.evalType {
+		case evalTypeAll:
+			e.success = e.success && eval.success
+		case evalTypeAny:
+			e.success = e.success || eval.success
+		case evalTypeNone:
+			e.success = e.success && !eval.success
+		default:
+			panic(fmt.Errorf("unexpected append to eval of type %q", e.evalType))
+		}
+	}
 }
diff --git a/cmd/cerbos/repl/internal/repl.go b/cmd/cerbos/repl/internal/repl.go
index 4a644457..53007e4a 100644
--- a/cmd/cerbos/repl/internal/repl.go
+++ b/cmd/cerbos/repl/internal/repl.go
@@ -743,7 +743,7 @@ func (r *REPL) evalCondition(ctx context.Context, id int) error {
 	e := r.doEvalCondition(ctx, condition)
 	eo := buildEvalOutput(e)
 
-	return pterm.DefaultTree.WithRoot(putils.TreeFromLeveledList(eo.tree)).Render()
+	return r.output.PrintTree(eo.tree)
 }
 
 func (r *REPL) doEvalCondition(ctx context.Context, condition *runtimev1.Condition) *eval {
@@ -770,7 +770,7 @@ func (r *REPL) doEvalCondition(ctx context.Context, condition *runtimev1.Conditi
 		}
 		return eval
 	case *runtimev1.Condition_Any:
-		eval := &eval{err: nil, success: true, evalType: evalTypeAny, evals: nil}
+		eval := &eval{err: nil, success: false, evalType: evalTypeAny, evals: nil}
 		for _, expr := range c.Any.GetExpr() {
 			e := r.doEvalCondition(ctx, expr)
 			eval.append(e)
@@ -1007,18 +1007,21 @@ type Output interface {
 	PrintRule(int, proto.Message) error
 	PrintJSON(any)
 	PrintYAML(proto.Message, int)
+	PrintTree(pterm.LeveledList) error
 	PrintErr(string, error)
 }
 
 type PrinterOutput struct {
 	*printer.Printer
-	level outputcolor.Level
+	treePrinter *pterm.TreePrinter
+	level       outputcolor.Level
 }
 
 func NewPrinterOutput(stdout, stderr io.Writer) *PrinterOutput {
 	return &PrinterOutput{
-		Printer: printer.New(stdout, stderr),
-		level:   outputcolor.DefaultLevel(),
+		Printer:     printer.New(stdout, stderr),
+		treePrinter: pterm.DefaultTree.WithWriter(stdout),
+		level:       outputcolor.DefaultLevel(),
 	}
 }
 
@@ -1081,6 +1084,10 @@ func (po *PrinterOutput) PrintYAML(obj proto.Message, indent int) {
 	po.Println()
 }
 
+func (po *PrinterOutput) PrintTree(tree pterm.LeveledList) error {
+	return po.treePrinter.WithRoot(putils.TreeFromLeveledList(tree)).Render()
+}
+
 func (po *PrinterOutput) PrintErr(msg string, err error) {
 	po.Println(colored.REPLError(fmt.Sprintf("Error: %s", msg)))
 	if err != nil {
diff --git a/go.sum b/go.sum
index 0887d47a..b0d7ff0c 100644
--- a/go.sum
+++ b/go.sum
@@ -161,8 +161,6 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
 github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
 github.com/bufbuild/protovalidate-go v0.9.1 h1:cdrIA33994yCcJyEIZRL36ZGTe9UDM/WHs5MBHEimiE=
 github.com/bufbuild/protovalidate-go v0.9.1/go.mod h1:5jptBxfvlY51RhX32zR6875JfPBRXUsQjyZjm/NqkLQ=
-github.com/bufbuild/protovalidate-go v0.9.2 h1:dUoPvFimovS74s3eeFNvHQOxFumRPsk390ifkzJCJ/4=
-github.com/bufbuild/protovalidate-go v0.9.2/go.mod h1:U9+WHAa6IOrLuqQEWPcxsyE4QEOTwm9fDpVbWXsR0zU=
 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
diff --git a/internal/printer/printer.go b/internal/printer/printer.go
index dd764b45..6d8b0b82 100644
--- a/internal/printer/printer.go
+++ b/internal/printer/printer.go
@@ -211,6 +211,9 @@ func (p *Printer) printTraceComponent(component *enginev1.Trace_Component) {
 	case enginev1.Trace_Component_KIND_RESOURCE:
 		p.Printf("%s%s", colored.TraceComponentKey("resource="), component.GetResource())
 
+	case enginev1.Trace_Component_KIND_ROLE:
+		p.Printf("%s%s", colored.TraceComponentKey("role="), component.GetRole())
+
 	case enginev1.Trace_Component_KIND_RULE:
 		p.Printf("%s%s", colored.TraceComponentKey("rule="), component.GetRule())
 


Test patch:
diff --git a/cmd/cerbos/repl/internal/repl_test.go b/cmd/cerbos/repl/internal/repl_test.go
index 257250e0..b49d1a6a 100644
--- a/cmd/cerbos/repl/internal/repl_test.go
+++ b/cmd/cerbos/repl/internal/repl_test.go
@@ -11,6 +11,7 @@ import (
 
 	"github.com/google/cel-go/common/types/ref"
 	"github.com/google/go-cmp/cmp"
+	"github.com/pterm/pterm"
 	"github.com/stretchr/testify/require"
 	"google.golang.org/protobuf/encoding/protojson"
 	"google.golang.org/protobuf/proto"
@@ -37,6 +38,7 @@ func TestREPL(t *testing.T) {
 	rpPath := filepath.Join(test.PathToDir(t, "store"), "resource_policies", "policy_01.yaml")
 	ppPath := filepath.Join(test.PathToDir(t, "store"), "principal_policies", "policy_01.yaml")
 	rpImportVariablesPath := filepath.Join(test.PathToDir(t, "store"), "resource_policies", "policy_09.yaml")
+	rpAnyAllNonePath := filepath.Join(test.PathToDir(t, "store"), "resource_policies", "policy_18.yaml")
 	ecPath := filepath.Join(test.PathToDir(t, "store"), "export_constants", "export_constants_01.yaml")
 	evPath := filepath.Join(test.PathToDir(t, "store"), "export_variables", "export_variables_01.yaml")
 	drConds := loadConditionsFromPolicy(t, drPath)
@@ -431,6 +433,59 @@ func TestREPL(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "exec",
+			directives: []DirectiveTest{
+				{
+					Directive: fmt.Sprintf(":load %s", rpAnyAllNonePath),
+				},
+				{
+					Directive: anyAllNoneResourceDirective(true, true),
+				},
+				{
+					Directive: ":exec #0",
+					Check:     checkAnyAllNoneTree(true, true, "all", true),
+				},
+				{
+					Directive: ":exec #1",
+					Check:     checkAnyAllNoneTree(true, true, "any", true),
+				},
+				{
+					Directive: ":exec #2",
+					Check:     checkAnyAllNoneTree(true, true, "none", false),
+				},
+				{
+					Directive: anyAllNoneResourceDirective(true, false),
+				},
+				{
+					Directive: ":exec #0",
+					Check:     checkAnyAllNoneTree(true, false, "all", false),
+				},
+				{
+					Directive: ":exec #1",
+					Check:     checkAnyAllNoneTree(true, false, "any", true),
+				},
+				{
+					Directive: ":exec #2",
+					Check:     checkAnyAllNoneTree(true, false, "none", false),
+				},
+				{
+					Directive: anyAllNoneResourceDirective(false, false),
+				},
+				{
+					Directive: ":exec #0",
+					Check:     checkAnyAllNoneTree(false, false, "all", false),
+				},
+				{
+					Directive: ":exec #1",
+					Check:     checkAnyAllNoneTree(false, false, "any", false),
+				},
+				{
+					Directive: ":exec #2",
+					Check:     checkAnyAllNoneTree(false, false, "none", true),
+				},
+			},
+		},
 	}
 
 	for _, tc := range testCases {
@@ -457,6 +512,21 @@ func TestREPL(t *testing.T) {
 	}
 }
 
+func anyAllNoneResourceDirective(a, b bool) string {
+	return fmt.Sprintf(`:let R = {"kind":"any_all_none","id":"1","attr":{"a":%t,"b":%t}}`, a, b)
+}
+
+func checkAnyAllNoneTree(a, b bool, operator string, result bool) func(*testing.T, *mockOutput) {
+	return func(t *testing.T, output *mockOutput) {
+		t.Helper()
+		require.Equal(t, pterm.LeveledList{
+			{Level: 0, Text: fmt.Sprintf("%s [%t]", operator, result)},
+			{Level: 1, Text: fmt.Sprintf("R.attr.a [%t]", a)},
+			{Level: 1, Text: fmt.Sprintf("R.attr.b [%t]", b)},
+		}, output.tree)
+	}
+}
+
 func TestIsTerminated(t *testing.T) {
 	testCases := []struct {
 		input    string
@@ -499,6 +569,7 @@ type mockOutput struct {
 	resultName string
 	resultVal  ref.Val
 	rules      []proto.Message
+	tree       pterm.LeveledList
 	err        error
 }
 
@@ -529,6 +600,11 @@ func (mo *mockOutput) PrintYAML(obj proto.Message, _ int) {
 	mo.yamlObj = obj
 }
 
+func (mo *mockOutput) PrintTree(tree pterm.LeveledList) error {
+	mo.tree = tree
+	return nil
+}
+
 func (mo *mockOutput) PrintErr(msg string, err error) {
 	mo.msg = msg
 	mo.err = err
diff --git a/internal/storage/index/builder_test.go b/internal/storage/index/builder_test.go
index c860d272..75748b4b 100644
--- a/internal/storage/index/builder_test.go
+++ b/internal/storage/index/builder_test.go
@@ -44,7 +44,7 @@ func TestBuildIndexWithDisk(t *testing.T) {
 
 	t.Run("check_contents", func(t *testing.T) {
 		data := idxImpl.Inspect()
-		require.Len(t, data, 49)
+		require.Len(t, data, 50)
 
 		rp1 := filepath.Join("resource_policies", "policy_01.yaml")
 		rp2 := filepath.Join("resource_policies", "policy_02.yaml")
diff --git a/internal/test/testdata/store/resource_policies/policy_18.yaml b/internal/test/testdata/store/resource_policies/policy_18.yaml
new file mode 100644
index 00000000..4b940137
--- /dev/null
+++ b/internal/test/testdata/store/resource_policies/policy_18.yaml
@@ -0,0 +1,42 @@
+# yaml-language-server: $schema=../../../../../schema/jsonschema/cerbos/policy/v1/Policy.schema.json
+---
+apiVersion: api.cerbos.dev/v1
+resourcePolicy:
+  version: default
+  resource: all_any_none
+  rules:
+    - actions:
+        - all
+      effect: EFFECT_ALLOW
+      roles:
+        - user
+      condition:
+        match:
+          all:
+            of:
+              - expr: R.attr.a
+              - expr: R.attr.b
+
+    - actions:
+        - any
+      effect: EFFECT_ALLOW
+      roles:
+        - user
+      condition:
+        match:
+          any:
+            of:
+              - expr: R.attr.a
+              - expr: R.attr.b
+
+    - actions:
+        - none
+      effect: EFFECT_ALLOW
+      roles:
+        - user
+      condition:
+        match:
+          none:
+            of:
+              - expr: R.attr.a
+              - expr: R.attr.b