diff --git a/cel/options.go b/cel/options.go index d7d2ab03..7d262995 100644 --- a/cel/options.go +++ b/cel/options.go @@ -583,7 +583,7 @@ func configToEnvOptions(config *env.Config, provider types.Provider, optFactorie // Configure the context variable declaration if config.ContextVariable != nil { - typeName := config.ContextVariable.TypeName + typeName := config.ContextVariable.GetType() if _, found := provider.FindStructType(typeName); !found { return nil, fmt.Errorf("invalid context proto type: %q", typeName) } diff --git a/common/env/env.go b/common/env/env.go index 936036ed..c3fc947d 100644 --- a/common/env/env.go +++ b/common/env/env.go @@ -324,6 +324,15 @@ type ContextVariable struct { // TypeName represents the fully qualified typename of the context variable. // Currently, only protobuf types are supported. TypeName string `yaml:"type_name"` + // Alternative spelling. + Type string `yaml:"type"` +} + +func (c *ContextVariable) GetType() string { + if c.TypeName != "" { + return c.TypeName + } + return c.Type } // Validate validates the context-variable configuration is well-formed. diff --git a/policy/BUILD.bazel b/policy/BUILD.bazel index 97da58e2..4569cd78 100644 --- a/policy/BUILD.bazel +++ b/policy/BUILD.bazel @@ -232,6 +232,33 @@ cel_go_test( test_suite = "testdata/nested_rules_variable_shadowing/tests.yaml", ) +cel_go_test( + name = "nested_rules_unconditional_chaining_policy", + cel_expr = "testdata/nested_rules_unconditional_chaining/policy.yaml", + config = "testdata/nested_rules_unconditional_chaining/config.yaml", + enable_coverage = True, + test_src = "//policy/test:cel_test_runner.go", + test_suite = "testdata/nested_rules_unconditional_chaining/tests.yaml", +) + +cel_go_test( + name = "nested_rules_unconditional_chaining_optional_policy", + cel_expr = "testdata/nested_rules_unconditional_chaining_optional/policy.yaml", + config = "testdata/nested_rules_unconditional_chaining_optional/config.yaml", + enable_coverage = True, + test_src = "//policy/test:cel_test_runner.go", + test_suite = "testdata/nested_rules_unconditional_chaining_optional/tests.yaml", +) + +cel_go_test( + name = "nested_rules_unwrap_rewrap_policy", + cel_expr = "testdata/nested_rules_unwrap_rewrap/policy.yaml", + config = "testdata/nested_rules_unwrap_rewrap/config.yaml", + enable_coverage = True, + test_src = "//policy/test:cel_test_runner.go", + test_suite = "testdata/nested_rules_unwrap_rewrap/tests.yaml", +) + cel_go_test( name = "variable_type_propagation_policy", cel_expr = "testdata/variable_type_propagation/policy.yaml", diff --git a/policy/README.md b/policy/README.md index 5a73f540..45acc45b 100644 --- a/policy/README.md +++ b/policy/README.md @@ -184,6 +184,56 @@ rule: - output: "'outer_default'" ``` +##### Using Unconditional Nested Rules + +In a first-match policy, a nested rule may be specified without an +explicit condition (or the condition set as exactly `true`). This is an +unconditional rule. + +Unconditional rules are useful for two primary purposes: + +_Scoping Variables_ + +Unconditional rules may declare variables (like foo in the example below) that +only apply to a specific group of matches. This avoids cluttering the global +scope. + +_Fallback Behavior (Chaining)_ + +The inner matches inside an unconditional rule do not need to cover every +possible scenario. If an input runs through the inner conditions and doesn't +find a match, the engine doesn't fail or return an optional none value. +Instead, it steps back out to the parent rule and continues down the list. + +Important: if an unconditional nested rule covers every possible scenario +(meaning it is exhaustive and will always generate an output), it must be the +last item in the parent rule's match list. Placing it anywhere else will +create dead code, as the engine can never reach the matches written beneath +it. + +Example: + +``` +# Policy is a string (not wrapped) +# input -> output +# 3 -> 'b' +# 2 -> 'c' +rule: + match: + - rule: + variables: + - name: "foo" + expression: "3" + match: + - condition: "input > variables.foo" + output: '"a"' + - condition: "input == variables.foo" + output: '"b"' + - output: '"c"' + +``` + + ### Imports When constructing complex object types such as protocol buffers, `imports` can diff --git a/policy/compiler.go b/policy/compiler.go index 674d918c..2ca84e0a 100644 --- a/policy/compiler.go +++ b/policy/compiler.go @@ -72,12 +72,17 @@ func (r *CompiledRule) HasOptionalOutput() bool { optionalOutput := false for _, m := range r.Matches() { if m.NestedRule() != nil && m.NestedRule().HasOptionalOutput() { - return true - } - if m.ConditionIsLiteral(types.True) { + // If the nested rule is unconditional, the matching may fallthrough to the next match + // in this context (unwrapping the optional value from the nested rule). + if !m.ConditionIsLiteral(types.True) { + return true + } + optionalOutput = true + } else if m.ConditionIsLiteral(types.True) { return false + } else { + optionalOutput = true } - optionalOutput = true } return optionalOutput } @@ -442,14 +447,16 @@ func (c *compiler) checkMatchOutputTypesAgree(rule *CompiledRule, iss *cel.Issue } func (c *compiler) checkUnreachableCode(rule *CompiledRule, iss *cel.Issues) { - ruleHasOptional := rule.HasOptionalOutput() compiledMatches := rule.Matches() matchCount := len(compiledMatches) for i := matchCount - 1; i >= 0; i-- { m := compiledMatches[i] triviallyTrue := m.ConditionIsLiteral(types.True) - if triviallyTrue && !ruleHasOptional && i != matchCount-1 { + // If the match is a single output or a nested rule that always returns a value, it is + // exhaustive. If the condition is trivially true, then all subsequent branches are unreachable. + isExhaustive := triviallyTrue && (m.NestedRule() == nil || !m.NestedRule().HasOptionalOutput()) + if isExhaustive && i != matchCount-1 { if m.Output() != nil { iss.ReportErrorAtID(m.SourceID(), "match creates unreachable outputs") } diff --git a/policy/compiler_test.go b/policy/compiler_test.go index f7b71682..0c2d253c 100644 --- a/policy/compiler_test.go +++ b/policy/compiler_test.go @@ -139,6 +139,20 @@ func TestCompiledRuleHasOptionalOutput(t *testing.T) { }, optional: true, }, + { + rule: &CompiledRule{ + matches: []*CompiledMatch{ + { + cond: mustCompileExpr(t, env, "true"), + nestedRule: &CompiledRule{ + matches: []*CompiledMatch{{cond: mustCompileExpr(t, env, "1 > 0")}}, + }, + }, + {cond: mustCompileExpr(t, env, "true")}, + }, + }, + optional: false, + }, } for _, tst := range tests { got := tst.rule.HasOptionalOutput() @@ -384,6 +398,9 @@ func (r *runner) run(t *testing.T) { } else if tc.Output.Value != nil { testOut = r.env.CELTypeAdapter().NativeToValue(tc.Output.Value) } + if testOut.Equal(out) == types.True { + return + } if optOut, ok := out.(*types.Optional); ok { if optOut.Equal(types.OptionalNone) == types.True { if testOut.Equal(types.OptionalNone) != types.True { @@ -392,7 +409,7 @@ func (r *runner) run(t *testing.T) { } else if testOut.Equal(optOut.GetValue()) != types.True { t.Errorf("policy eval got %v, wanted %v", out, testOut) } - } else if testOut.Equal(out) != types.True { + } else { t.Errorf("policy eval got %v, wanted %v", out, testOut) } }) diff --git a/policy/helper_test.go b/policy/helper_test.go index e11b06ea..e3f50102 100644 --- a/policy/helper_test.go +++ b/policy/helper_test.go @@ -210,6 +210,27 @@ var ( : optional.none()))) : optional.of(@index3.format([@index0, @index2])))`, }, + { + name: "nested_rules_unconditional_chaining", + expr: ` + cel.@block([3], + ((x > @index0) ? optional.of("a") : ((x == @index0) ? optional.of("b") : optional.none())) + .orValue("c"))`, + }, + { + name: "nested_rules_unconditional_chaining_optional", + expr: ` + cel.@block([3], + ((x > @index0) ? optional.of("a") : ((x == @index0) ? optional.of("b") : optional.none())) + .or((x == 1) ? optional.of("c") : optional.none()))`, + }, + { + name: "nested_rules_unwrap_rewrap", + expr: ` + (x == 1) + ? optional.of(((y == 1) ? optional.of("a") : optional.none()).orValue("b")) + : optional.none()`, + }, } composerUnnestTests = []struct { diff --git a/policy/testdata/nested_rules_unconditional_chaining/config.yaml b/policy/testdata/nested_rules_unconditional_chaining/config.yaml new file mode 100644 index 00000000..51eee6f6 --- /dev/null +++ b/policy/testdata/nested_rules_unconditional_chaining/config.yaml @@ -0,0 +1,18 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: nested_rules_unconditional_chaining +variables: + - name: x + type: int diff --git a/policy/testdata/nested_rules_unconditional_chaining/policy.yaml b/policy/testdata/nested_rules_unconditional_chaining/policy.yaml new file mode 100644 index 00000000..e0e04f03 --- /dev/null +++ b/policy/testdata/nested_rules_unconditional_chaining/policy.yaml @@ -0,0 +1,27 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: nested_rules_unconditional_chaining +rule: + match: + - rule: + variables: + - name: foo + expression: "3" + match: + - condition: "x > variables.foo" + output: "'a'" + - condition: "x == variables.foo" + output: "'b'" + - output: "'c'" diff --git a/policy/testdata/nested_rules_unconditional_chaining/tests.yaml b/policy/testdata/nested_rules_unconditional_chaining/tests.yaml new file mode 100644 index 00000000..e13f5e47 --- /dev/null +++ b/policy/testdata/nested_rules_unconditional_chaining/tests.yaml @@ -0,0 +1,36 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +description: "Nested rule tests for unconditional chaining" +section: + - name: "chaining" + tests: + - name: "sub_rule_match_greater" + input: + x: + expr: "4" + output: + value: "a" + - name: "sub_rule_match_equal" + input: + x: + expr: "3" + output: + value: "b" + - name: "parent_rule_fallback" + input: + x: + expr: "2" + output: + value: "c" diff --git a/policy/testdata/nested_rules_unconditional_chaining_optional/config.yaml b/policy/testdata/nested_rules_unconditional_chaining_optional/config.yaml new file mode 100644 index 00000000..3a6c66d6 --- /dev/null +++ b/policy/testdata/nested_rules_unconditional_chaining_optional/config.yaml @@ -0,0 +1,18 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: nested_rules_unconditional_chaining_optional +variables: + - name: x + type: int diff --git a/policy/testdata/nested_rules_unconditional_chaining_optional/policy.yaml b/policy/testdata/nested_rules_unconditional_chaining_optional/policy.yaml new file mode 100644 index 00000000..f7e9fc16 --- /dev/null +++ b/policy/testdata/nested_rules_unconditional_chaining_optional/policy.yaml @@ -0,0 +1,28 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: nested_rules_unconditional_chaining_optional +rule: + match: + - rule: + variables: + - name: foo + expression: "3" + match: + - condition: "x > variables.foo" + output: "'a'" + - condition: "x == variables.foo" + output: "'b'" + - condition: "x == 1" + output: "'c'" diff --git a/policy/testdata/nested_rules_unconditional_chaining_optional/tests.yaml b/policy/testdata/nested_rules_unconditional_chaining_optional/tests.yaml new file mode 100644 index 00000000..31fb3fc1 --- /dev/null +++ b/policy/testdata/nested_rules_unconditional_chaining_optional/tests.yaml @@ -0,0 +1,42 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +description: "Nested rule tests for unconditional chaining where parent is optional" +section: + - name: "chaining" + tests: + - name: "sub_rule_match_greater" + input: + x: + expr: "4" + output: + value: "a" + - name: "sub_rule_match_equal" + input: + x: + expr: "3" + output: + value: "b" + - name: "parent_rule_match" + input: + x: + expr: "1" + output: + value: "c" + - name: "parent_rule_fallback_none" + input: + x: + expr: "2" + output: + expr: "optional.none()" diff --git a/policy/testdata/nested_rules_unwrap_rewrap/config.yaml b/policy/testdata/nested_rules_unwrap_rewrap/config.yaml new file mode 100644 index 00000000..29f5c0cd --- /dev/null +++ b/policy/testdata/nested_rules_unwrap_rewrap/config.yaml @@ -0,0 +1,20 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: nested_rules_unwrap_rewrap +variables: + - name: x + type: int + - name: y + type: int diff --git a/policy/testdata/nested_rules_unwrap_rewrap/policy.yaml b/policy/testdata/nested_rules_unwrap_rewrap/policy.yaml new file mode 100644 index 00000000..7d9fe886 --- /dev/null +++ b/policy/testdata/nested_rules_unwrap_rewrap/policy.yaml @@ -0,0 +1,25 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: nested_rules_unwrap_rewrap +rule: + match: + - condition: "x == 1" + rule: + match: + - rule: + match: + - condition: "y == 1" + output: "'a'" + - output: "'b'" diff --git a/policy/testdata/nested_rules_unwrap_rewrap/tests.yaml b/policy/testdata/nested_rules_unwrap_rewrap/tests.yaml new file mode 100644 index 00000000..6a3a89e0 --- /dev/null +++ b/policy/testdata/nested_rules_unwrap_rewrap/tests.yaml @@ -0,0 +1,42 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +description: "Nested rule tests for unwrapping optional child and rewrapping into optional parent" +section: + - name: "unwrap_rewrap" + tests: + - name: "outer_rule_no_match" + input: + x: + expr: "2" + y: + expr: "1" + output: + expr: "optional.none()" + - name: "outer_match_innter_match" + input: + x: + expr: "1" + y: + expr: "1" + output: + expr: "optional.of('a')" + - name: "outer_match_inner_fallthrough" + input: + x: + expr: "1" + y: + expr: "2" + output: + expr: "optional.of('b')"