diff --git a/policy/src/main/java/dev/cel/policy/BUILD.bazel b/policy/src/main/java/dev/cel/policy/BUILD.bazel index 79c7a1f05..f66ef64d1 100644 --- a/policy/src/main/java/dev/cel/policy/BUILD.bazel +++ b/policy/src/main/java/dev/cel/policy/BUILD.bazel @@ -138,6 +138,7 @@ java_library( ], deps = [ ":compiler", + "//common:cel_ast", "//optimizer:ast_optimizer", "@maven//:com_google_errorprone_error_prone_annotations", ], @@ -201,14 +202,18 @@ java_library( ":compiled_rule", ":compiler", ":compiler_builder", + ":decision_linker", ":policy", ":rule_composer", ":source", ":validation_exception", "//bundle:cel", "//common:cel_ast", + "//common:cel_issue", "//common:cel_source", - "//common:compiler_common", + "//common:cel_validation_exception", + "//common:cel_validation_result", + "//common:cel_var_decl", "//common:container", "//common:source_location", "//common/ast", @@ -221,6 +226,7 @@ java_library( "//optimizer:optimizer_builder", "//optimizer/optimizers:common_subexpression_elimination", "//optimizer/optimizers:constant_folding", + "//optimizer/optimizers:select_optimizer", "//validator", "//validator:ast_validator", "//validator:validator_builder", @@ -268,3 +274,27 @@ java_library( "@maven//:org_jspecify_jspecify", ], ) + +java_library( + name = "decision_linker", + srcs = ["PolicyDecisionLinker.java"], + visibility = ["//visibility:private"], + deps = [ + "//bundle:cel", + "//common:cel_ast", + "//common:cel_source", + "//common:cel_var_decl", + "//common:mutable_ast", + "//common/ast", + "//common/ast:cel_block", + "//common/ast:mutable_expr", + "//common/navigation", + "//common/navigation:mutable_navigation", + "//common/types:type_providers", + "//extensions:bindings", + "//optimizer:ast_optimizer", + "//optimizer:mutable_ast", + "@maven//:com_google_errorprone_error_prone_annotations", + "@maven//:com_google_guava_guava", + ], +) diff --git a/policy/src/main/java/dev/cel/policy/CelPolicyCompilerBuilder.java b/policy/src/main/java/dev/cel/policy/CelPolicyCompilerBuilder.java index 4089477a1..582496280 100644 --- a/policy/src/main/java/dev/cel/policy/CelPolicyCompilerBuilder.java +++ b/policy/src/main/java/dev/cel/policy/CelPolicyCompilerBuilder.java @@ -16,8 +16,10 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.CheckReturnValue; +import dev.cel.common.CelAbstractSyntaxTree; import dev.cel.optimizer.CelAstOptimizer; import java.util.List; +import java.util.Map; /** Interface for building an instance of {@link CelPolicyCompiler} */ public interface CelPolicyCompilerBuilder { @@ -44,6 +46,13 @@ public interface CelPolicyCompilerBuilder { @CanIgnoreReturnValue CelPolicyCompilerBuilder setOptimizers(List optimizers); + /** + * Statically links separately compiled policies, keyed by the qualified identifier the policy + * references them by (e.g. {@code decisions.channel}). + */ + @CanIgnoreReturnValue + CelPolicyCompilerBuilder addDecisions(Map decisions); + @CheckReturnValue CelPolicyCompiler build(); } diff --git a/policy/src/main/java/dev/cel/policy/CelPolicyCompilerImpl.java b/policy/src/main/java/dev/cel/policy/CelPolicyCompilerImpl.java index f43ed57c1..a5b7f5fc9 100644 --- a/policy/src/main/java/dev/cel/policy/CelPolicyCompilerImpl.java +++ b/policy/src/main/java/dev/cel/policy/CelPolicyCompilerImpl.java @@ -14,10 +14,13 @@ package dev.cel.policy; +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.stream.Collectors.partitioningBy; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.errorprone.annotations.CanIgnoreReturnValue; import dev.cel.bundle.Cel; import dev.cel.common.CelAbstractSyntaxTree; @@ -39,6 +42,7 @@ import dev.cel.optimizer.CelOptimizerFactory; import dev.cel.optimizer.optimizers.ConstantFoldingOptimizer; import dev.cel.optimizer.optimizers.ConstantFoldingOptimizer.ConstantFoldingOptions; +import dev.cel.optimizer.optimizers.SelectOptimizer; import dev.cel.optimizer.optimizers.SubexpressionOptimizer; import dev.cel.optimizer.optimizers.SubexpressionOptimizer.SubexpressionOptimizerOptions; import dev.cel.policy.CelCompiledRule.CelCompiledMatch; @@ -57,6 +61,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.Optional; /** Package-private implementation for policy compiler. */ @@ -68,6 +73,7 @@ final class CelPolicyCompilerImpl implements CelPolicyCompiler { private final int iterationLimit; private final ImmutableList optimizers; private final Optional astDepthValidator; + private final ImmutableMap decisions; @Override public CelCompiledRule compileRule(CelPolicy policy) throws CelPolicyValidationException { @@ -144,8 +150,15 @@ public CelAbstractSyntaxTree compose(CelPolicy policy, CelCompiledRule compiledR throw new CelPolicyValidationException("Unexpected error while composing rules.", e); } + // Linking runs in a fresh environment after CSE, as it renumbers the cel.@block indices that + // CSE has already declared. SelectOptimizer must observe the linked AST. + Map> isPostLinkOptimizer = + optimizers.stream() + .collect(partitioningBy(o -> !decisions.isEmpty() && o instanceof SelectOptimizer)); CelOptimizer astOptimizer = - CelOptimizerFactory.standardCelOptimizerBuilder(cel).addAstOptimizers(optimizers).build(); + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers(isPostLinkOptimizer.get(false)) + .build(); try { // Optimize the composed graph using const fold and CSE ast = astOptimizer.optimize(ast); @@ -154,6 +167,20 @@ public CelAbstractSyntaxTree compose(CelPolicy policy, CelCompiledRule compiledR "Failed to optimize the composed policy. Reason: " + e.getMessage(), e); } + if (!decisions.isEmpty()) { + CelOptimizer linkingOptimizer = + CelOptimizerFactory.standardCelOptimizerBuilder(cel) + .addAstOptimizers(PolicyDecisionLinker.newInstance(decisions, iterationLimit)) + .addAstOptimizers(isPostLinkOptimizer.get(true)) + .build(); + try { + ast = linkingOptimizer.optimize(ast); + } catch (CelOptimizationException e) { + throw new CelPolicyValidationException( + "Failed to link policy decisions. Reason: " + e.getMessage(), e); + } + } + assertAstDepthIsSafe(ast, cel); return ast; @@ -370,10 +397,12 @@ static final class Builder implements CelPolicyCompilerBuilder { private int iterationLimit; private ImmutableList optimizers; private Optional astDepthLimitValidator; + private final ImmutableMap.Builder decisions; private Builder(Cel cel) { this.cel = cel; this.astDepthLimitValidator = Optional.of(AstDepthLimitValidator.DEFAULT); + this.decisions = ImmutableMap.builder(); } @Override @@ -402,15 +431,47 @@ public Builder setAstDepthLimit(int astDepthLimit) { } @Override + @CanIgnoreReturnValue public Builder setOptimizers(List optimizers) { this.optimizers = ImmutableList.copyOf(optimizers); return this; } + @Override + @CanIgnoreReturnValue + public Builder addDecisions(Map decisions) { + this.decisions.putAll(checkNotNull(decisions)); + return this; + } + @Override public CelPolicyCompiler build() { + ImmutableMap decisions = this.decisions.buildOrThrow(); + ImmutableList.Builder decisionDecls = ImmutableList.builder(); + for (Map.Entry decision : decisions.entrySet()) { + String name = decision.getKey(); + // Qualified names cannot be captured by comprehension variables, and '@' is reserved for + // optimizer generated identifiers (e.g. @index0). + checkArgument( + name.contains(".") && !name.startsWith("@") && !name.startsWith(variablesPrefix), + "Invalid decision name: '%s'. Decision names must be qualified (e.g. decisions.foo).", + name); + checkArgument(decision.getValue().isChecked(), "Decision '%s' must be type-checked.", name); + decisionDecls.add(CelVarDecl.newVarDeclaration(name, decision.getValue().getResultType())); + } + // Without namespaced declarations, qualified decision references remain unlinked selects. + checkArgument( + decisions.isEmpty() || cel.toCheckerBuilder().options().enableNamespacedDeclarations(), + "Linking decisions requires enableNamespacedDeclarations."); return new CelPolicyCompilerImpl( - cel, this.variablesPrefix, this.iterationLimit, this.optimizers, astDepthLimitValidator); + decisions.isEmpty() + ? cel + : cel.toCelBuilder().addVarDeclarations(decisionDecls.build()).build(), + this.variablesPrefix, + this.iterationLimit, + this.optimizers, + astDepthLimitValidator, + decisions); } } @@ -436,11 +497,13 @@ private CelPolicyCompilerImpl( String variablesPrefix, int iterationLimit, ImmutableList optimizers, - Optional astDepthValidator) { + Optional astDepthValidator, + ImmutableMap decisions) { this.cel = checkNotNull(cel); this.variablesPrefix = checkNotNull(variablesPrefix); this.iterationLimit = iterationLimit; this.optimizers = optimizers; this.astDepthValidator = astDepthValidator; + this.decisions = decisions; } } diff --git a/policy/src/main/java/dev/cel/policy/PolicyDecisionLinker.java b/policy/src/main/java/dev/cel/policy/PolicyDecisionLinker.java new file mode 100644 index 000000000..e8219507b --- /dev/null +++ b/policy/src/main/java/dev/cel/policy/PolicyDecisionLinker.java @@ -0,0 +1,222 @@ +// 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. + +package dev.cel.policy; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableSet.toImmutableSet; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Sets; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import dev.cel.bundle.Cel; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelMutableAst; +import dev.cel.common.CelSource.Extension; +import dev.cel.common.CelSource.Extension.Component; +import dev.cel.common.CelSource.Extension.Version; +import dev.cel.common.CelVarDecl; +import dev.cel.common.ast.CelBlock; +import dev.cel.common.ast.CelExpr; +import dev.cel.common.ast.CelExpr.ExprKind.Kind; +import dev.cel.common.ast.CelMutableExpr; +import dev.cel.common.ast.CelMutableExpr.CelMutableList; +import dev.cel.common.navigation.CelNavigableAst; +import dev.cel.common.navigation.CelNavigableMutableExpr; +import dev.cel.common.types.CelType; +import dev.cel.extensions.CelBindingsExtensions; +import dev.cel.optimizer.AstMutator; +import dev.cel.optimizer.CelAstOptimizer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Optimizer that statically links separately compiled policies, referenced by the composed policy + * as decision identifiers, into a single {@code cel.@block}. + */ +final class PolicyDecisionLinker implements CelAstOptimizer { + // Mirrors SubexpressionOptimizer's tag, needed when linking introduces the first cel.@block. + private static final Extension CEL_BLOCK_AST_EXTENSION_TAG = + Extension.create("cel_block", Version.of(1L, 1L), Component.COMPONENT_RUNTIME); + + private final ImmutableMap decisions; + private final int iterationLimit; + + static PolicyDecisionLinker newInstance( + ImmutableMap decisions, int iterationLimit) { + return new PolicyDecisionLinker(decisions, iterationLimit); + } + + @Override + public OptimizationResult optimize(CelAbstractSyntaxTree ast, Cel cel) { + ImmutableSet referencedIdents = collectIdents(ast); + ImmutableList linkedNames = + decisions.keySet().stream().filter(referencedIdents::contains).collect(toImmutableList()); + if (linkedNames.isEmpty()) { + return OptimizationResult.create(ast); + } + + List decisionAsts = new ArrayList<>(); + for (String name : linkedNames) { + CelAbstractSyntaxTree decisionAst = decisions.get(name); + // Linking only resolves the composed policy's references, so a decision referencing another + // decision would otherwise type-check here and fail at runtime as an unbound variable. + Set unresolved = Sets.intersection(collectIdents(decisionAst), decisions.keySet()); + checkArgument( + unresolved.isEmpty(), + "Decision '%s' references unlinked decisions %s. Link them when compiling '%s'.", + name, + unresolved, + name); + decisionAsts.add(decisionAst); + } + + // The composed policy already fits within the iteration limit, but the linked AST also + // includes every decision. + AstMutator astMutator = + AstMutator.newInstance( + iterationLimit + + decisionAsts.stream() + .mapToLong(d -> CelNavigableAst.fromAst(d).getRoot().allNodes().count()) + .sum()); + List mutableInputs = new ArrayList<>(); + for (CelAbstractSyntaxTree decisionAst : decisionAsts) { + mutableInputs.add(CelMutableAst.fromCelAst(decisionAst)); + } + // Lazily evaluated slots may be referenced inside the composed policy's comprehensions, so its + // iteration variables must not shadow the decisions' free variables. + mutableInputs.add( + astMutator + .mangleComprehensionIdentifierNames(CelMutableAst.fromCelAst(ast), "@it", "@it2", "@ac") + .mutableAst()); + + // Stabilizes all inputs into disjoint expression ID ranges while merging their macro calls. + CelMutableAst combined = astMutator.newGlobalCall(CelBlock.FUNCTION_NAME, mutableInputs); + CelMutableExpr linkCall = combined.expr(); + List pieces = linkCall.call().args(); + + SlotTable slotTable = new SlotTable(astMutator); + Map decisionRenames = new HashMap<>(); + for (int i = 0; i < linkedNames.size(); i++) { + CelMutableExpr body = link(decisionAsts.get(i), pieces.get(i), new HashMap<>(), slotTable); + decisionRenames.put( + linkedNames.get(i), + CelBlock.INDEX_PREFIX + slotTable.add(body, decisionAsts.get(i).getResultType())); + } + CelMutableExpr rootBody = link(ast, pieces.get(linkedNames.size()), decisionRenames, slotTable); + + // The link call has the highest ID, so the new list node can safely take the next one. + linkCall + .call() + .setArgs( + ImmutableList.of( + CelMutableExpr.ofList(linkCall.id() + 1, CelMutableList.create(slotTable.slots)), + rootBody)); + combined.source().addAllExtensions(ImmutableList.of(CEL_BLOCK_AST_EXTENSION_TAG)); + return OptimizationResult.create( + astMutator + .renumberIdsConsecutively(CelMutableAst.of(linkCall, combined.source())) + .toParsedAst(), + ImmutableList.copyOf(slotTable.slotDecls), + ImmutableList.of(CelBindingsExtensions.CEL_BLOCK_FUNCTION_DECL)); + } + + /** + * Moves the input's block slots into the slot table and returns its renamed body. {@code renames} + * is extended with the input's local slot renames. + */ + private static CelMutableExpr link( + CelAbstractSyntaxTree input, + CelMutableExpr piece, + Map renames, + SlotTable slotTable) { + Optional block = CelBlock.extract(input); + if (!block.isPresent()) { + return rename(piece, renames); + } + List localSlots = piece.call().args().get(0).list().elements(); + ImmutableList typedSlots = block.get().indices(); + for (int k = 0; k < localSlots.size(); k++) { + int index = + slotTable.add( + rename(localSlots.get(k), renames), input.getTypeOrThrow(typedSlots.get(k).id())); + renames.put(CelBlock.INDEX_PREFIX + k, CelBlock.INDEX_PREFIX + index); + } + return rename(piece.call().args().get(1), renames); + } + + @CanIgnoreReturnValue + private static CelMutableExpr rename(CelMutableExpr expr, Map renames) { + CelNavigableMutableExpr.fromExpr(expr) + .allNodes() + .filter(node -> node.getKind().equals(Kind.IDENT)) + .forEach( + node -> { + String newName = renames.get(node.expr().ident().name()); + if (newName != null) { + node.expr().ident().setName(newName); + } + }); + return expr; + } + + private static ImmutableSet collectIdents(CelAbstractSyntaxTree ast) { + return CelNavigableAst.fromAst(ast) + .getRoot() + .allNodes() + .filter(node -> node.getKind().equals(Kind.IDENT)) + .map(node -> node.expr().ident().name()) + .collect(toImmutableSet()); + } + + // Merged slots across all linked ASTs, deduplicated by shape so that a policy reached through + // multiple paths (e.g. a diamond dependency) is emitted and evaluated at most once. + private static final class SlotTable { + private final AstMutator astMutator; + private final List slots; + private final List slotDecls; + private final Map slotIndexByShape; + + private int add(CelMutableExpr slot, CelType type) { + CelMutableExpr shape = astMutator.clearExprIds(CelMutableExpr.newInstance(slot)); + Integer existingIndex = slotIndexByShape.putIfAbsent(shape, slots.size()); + if (existingIndex != null) { + return existingIndex; + } + slotDecls.add(CelVarDecl.newVarDeclaration(CelBlock.INDEX_PREFIX + slots.size(), type)); + slots.add(slot); + return slots.size() - 1; + } + + private SlotTable(AstMutator astMutator) { + this.astMutator = astMutator; + this.slots = new ArrayList<>(); + this.slotDecls = new ArrayList<>(); + this.slotIndexByShape = new HashMap<>(); + } + } + + private PolicyDecisionLinker( + ImmutableMap decisions, int iterationLimit) { + this.decisions = decisions; + this.iterationLimit = iterationLimit; + } +} diff --git a/policy/src/main/java/dev/cel/policy/tools/BUILD.bazel b/policy/src/main/java/dev/cel/policy/tools/BUILD.bazel index 140c44b2c..b4e646c14 100644 --- a/policy/src/main/java/dev/cel/policy/tools/BUILD.bazel +++ b/policy/src/main/java/dev/cel/policy/tools/BUILD.bazel @@ -36,6 +36,7 @@ java_library( "//policy:compiler_factory", "//policy:parser", "//policy:parser_factory", + "@cel_spec//proto/cel/expr:checked_java_proto", "@maven//:com_google_guava_guava", "@maven//:com_google_protobuf_protobuf_java", "@maven//:info_picocli_picocli", diff --git a/policy/src/main/java/dev/cel/policy/tools/CelPolicyCompilerTool.java b/policy/src/main/java/dev/cel/policy/tools/CelPolicyCompilerTool.java index 4c8bf8395..bb7f3516e 100644 --- a/policy/src/main/java/dev/cel/policy/tools/CelPolicyCompilerTool.java +++ b/policy/src/main/java/dev/cel/policy/tools/CelPolicyCompilerTool.java @@ -16,8 +16,10 @@ import static java.nio.charset.StandardCharsets.UTF_8; +import dev.cel.expr.CheckedExpr; import com.google.common.base.Ascii; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.protobuf.DescriptorProtos.FileDescriptorSet; import com.google.protobuf.Descriptors.FileDescriptor; @@ -58,6 +60,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.Callable; import picocli.CommandLine; import picocli.CommandLine.Model.CommandSpec; @@ -127,6 +131,14 @@ public final class CelPolicyCompilerTool implements Callable { description = "Enable inline variable definitions (e.g., '- var_name: expr') in the policy") private boolean simpleVariables = false; + @SuppressWarnings("FieldCanBeFinal") // Populated reflectively by picocli + @Option( + names = {"--decision"}, + description = + "Separately compiled policy (canonical CheckedExpr binarypb) to link, as" + + " =. e.g. decisions.channel=channel.binarypb") + private Map decisionPaths = new LinkedHashMap<>(); + @Option( names = {"--iteration_limit"}, defaultValue = "1000", @@ -244,6 +256,11 @@ public Integer call() { CelPolicyCompilerFactory.newPolicyCompiler(cel) .setIterationLimit(iterationLimit) .setOptimizers(optimizersBuilder.build()); + ImmutableMap.Builder decisions = ImmutableMap.builder(); + for (Map.Entry decision : decisionPaths.entrySet()) { + decisions.put(decision.getKey(), loadCheckedExpr(decision.getValue())); + } + policyCompilerBuilder.addDecisions(decisions.buildOrThrow()); CelPolicyCompiler policyCompiler = policyCompilerBuilder.build(); CelAbstractSyntaxTree ast = policyCompiler.compile(policy); @@ -316,6 +333,12 @@ private static void validatePolicyFileType(String path, String description) { } } + private static CelAbstractSyntaxTree loadCheckedExpr(String path) throws IOException { + return CelProtoAbstractSyntaxTree.fromCheckedExpr( + CheckedExpr.parseFrom(readFileBytes(path), ExtensionRegistry.getEmptyRegistry())) + .getAst(); + } + private static FileDescriptorSet load(String descriptorSetPath) { try { byte[] descriptorBytes = readFileBytes(descriptorSetPath); diff --git a/policy/src/test/java/dev/cel/policy/BUILD.bazel b/policy/src/test/java/dev/cel/policy/BUILD.bazel index 4b8d757db..2e51fbb4c 100644 --- a/policy/src/test/java/dev/cel/policy/BUILD.bazel +++ b/policy/src/test/java/dev/cel/policy/BUILD.bazel @@ -21,16 +21,25 @@ java_library( "//bundle:environment", "//bundle:environment_yaml_parser", "//common:cel_ast", + "//common:cel_function_decl", + "//common:cel_overload_decl", "//common:options", + "//common:proto_ast", "//common/formats:value_string", "//common/internal", "//common/resources/testdata/proto3:standalone_global_enum_java_proto", "//common/types", + "//extensions", "//extensions:optional_library", + "//optimizer/optimizers:common_subexpression_elimination", + "//optimizer/optimizers:constant_folding", + "//optimizer/optimizers:select_optimizer", "//parser:macro", "//parser:unparser", "//policy", "//policy:compiled_rule", + "//policy:compiler", + "//policy:compiler_builder", "//policy:compiler_factory", "//policy:parser", "//policy:parser_factory", diff --git a/policy/src/test/java/dev/cel/policy/CelPolicyDecisionsTest.java b/policy/src/test/java/dev/cel/policy/CelPolicyDecisionsTest.java new file mode 100644 index 000000000..9afcd6891 --- /dev/null +++ b/policy/src/test/java/dev/cel/policy/CelPolicyDecisionsTest.java @@ -0,0 +1,531 @@ +// 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. + +package dev.cel.policy; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.google.testing.junit.testparameterinjector.TestParameter; +import com.google.testing.junit.testparameterinjector.TestParameterInjector; +import com.google.testing.junit.testparameterinjector.TestParameters; +import dev.cel.bundle.Cel; +import dev.cel.common.CelAbstractSyntaxTree; +import dev.cel.common.CelFunctionDecl; +import dev.cel.common.CelOptions; +import dev.cel.common.CelOverloadDecl; +import dev.cel.common.CelProtoAbstractSyntaxTree; +import dev.cel.common.types.SimpleType; +import dev.cel.common.types.StructTypeReference; +import dev.cel.expr.conformance.proto3.TestAllTypes; +import dev.cel.extensions.CelExtensions; +import dev.cel.extensions.CelOptionalLibrary; +import dev.cel.optimizer.optimizers.ConstantFoldingOptimizer; +import dev.cel.optimizer.optimizers.SelectOptimizer; +import dev.cel.optimizer.optimizers.SelectOptimizer.SelectOptimizerOptions; +import dev.cel.optimizer.optimizers.SubexpressionOptimizer; +import dev.cel.parser.CelStandardMacro; +import dev.cel.parser.CelUnparserFactory; +import dev.cel.runtime.CelFunctionBinding; +import dev.cel.testing.CelRuntimeFlavor; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(TestParameterInjector.class) +public final class CelPolicyDecisionsTest { + private static final CelPolicyParser POLICY_PARSER = + CelPolicyParserFactory.newYamlParserBuilder().build(); + + // Cheap precondition over the message channel. + private static final String CHANNEL_POLICY = + "name: channel\n" + + "rule:\n" + + " match:\n" + + " - condition: \"channel == 'sms'\"\n" + + " output: \"'SMS'\"\n" + + " - output: \"'UNKNOWN'\"\n"; + + // Expensive sub-decision backed by an ML model. + private static final String TEXT_RISK_POLICY = + "name: text_risk\n" + + "rule:\n" + + " variables:\n" + + " - name: score\n" + + " expression: \"model(text)\"\n" + + " match:\n" + + " - condition: \"variables.score > 5\"\n" + + " output: \"'HIGH'\"\n" + + " - output: \"'LOW'\"\n"; + + private static final String WORKFLOW_POLICY = + "name: workflow\n" + + "rule:\n" + + " match:\n" + + " - condition: \"decisions.channel == 'UNKNOWN'\"\n" + + " output: \"'IGNORE'\"\n" + + " - condition: \"decisions.text_risk == 'HIGH'\"\n" + + " output: \"'BLOCK'\"\n" + + " - output: \"'ALLOW'\"\n"; + + // Static so that the @Immutable function binding does not capture mutable instance state. + private static final AtomicInteger modelInvocations = new AtomicInteger(); + + @TestParameter private CelRuntimeFlavor runtimeFlavor; + + private Cel cel; + + @Before + public void setUp() { + modelInvocations.set(0); + cel = + runtimeFlavor + .builder() + .setStandardMacros(CelStandardMacro.STANDARD_MACROS) + .addCompilerLibraries(CelOptionalLibrary.INSTANCE, CelExtensions.bindings()) + .addRuntimeLibraries(CelOptionalLibrary.INSTANCE) + .addMessageTypes(TestAllTypes.getDescriptor()) + .setOptions( + CelOptions.current() + .populateMacroCalls(true) + .enableHeterogeneousNumericComparisons(true) + .build()) + .addVar("channel", SimpleType.STRING) + .addVar("text", SimpleType.STRING) + .addVar("msg", StructTypeReference.create(TestAllTypes.getDescriptor().getFullName())) + .addFunctionDeclarations( + CelFunctionDecl.newFunctionDeclaration( + "model", + CelOverloadDecl.newGlobalOverload( + "model_string", SimpleType.INT, SimpleType.STRING))) + .addFunctionBindings( + CelFunctionBinding.fromOverloads( + "model", + CelFunctionBinding.from( + "model_string", + String.class, + text -> { + modelInvocations.incrementAndGet(); + return (long) text.length(); + }))) + .build(); + } + + @Test + public void linkedDecision_decisionsBecomeBlockSlots() throws Exception { + CelAbstractSyntaxTree ast = compileWorkflow(); + + assertThat(CelUnparserFactory.newUnparser().unparse(ast)) + .isEqualTo( + "cel.@block([(channel == \"sms\") ? \"SMS\" : \"UNKNOWN\", (model(text) > 5) ?" + + " \"HIGH\" : \"LOW\"], (@index0 == \"UNKNOWN\") ? \"IGNORE\" : ((@index1 ==" + + " \"HIGH\") ? \"BLOCK\" : \"ALLOW\"))"); + } + + @Test + @TestParameters("{channel: email, expected: IGNORE, expectedInvocations: 0}") + @TestParameters("{channel: sms, expected: BLOCK, expectedInvocations: 1}") + public void linkedDecision_expensiveDecisionEvaluatedOnlyWhenReached( + String channel, String expected, int expectedInvocations) throws Exception { + CelAbstractSyntaxTree ast = compileWorkflow(); + + Object result = eval(ast, ImmutableMap.of("channel", channel, "text", "long message")); + + assertThat(result).isEqualTo(expected); + assertThat(modelInvocations.get()).isEqualTo(expectedInvocations); + } + + @Test + public void linkedDecision_diamond_sharedDecisionEvaluatedOnce() throws Exception { + String smsAbuse = + "name: sms_abuse\n" + + "rule:\n" + + " match:\n" + + " - condition: \"decisions.text_risk == 'HIGH' && channel == 'sms'\"\n" + + " output: \"true\"\n" + + " - output: \"false\"\n"; + String emailAbuse = + "name: email_abuse\n" + + "rule:\n" + + " match:\n" + + " - condition: \"decisions.text_risk == 'HIGH' && channel == 'email'\"\n" + + " output: \"true\"\n" + + " - output: \"false\"\n"; + String workflow = + "name: diamond\n" + + "rule:\n" + + " match:\n" + + " - condition: \"decisions.sms_abuse || decisions.email_abuse\"\n" + + " output: \"'BLOCK'\"\n" + + " - output: \"'ALLOW'\"\n"; + ImmutableMap textRisk = + ImmutableMap.of("decisions.text_risk", compile(TEXT_RISK_POLICY)); + CelAbstractSyntaxTree ast = + compile( + workflow, + ImmutableMap.of( + "decisions.sms_abuse", compile(smsAbuse, textRisk), + "decisions.email_abuse", compile(emailAbuse, textRisk))); + + Object result = eval(ast, ImmutableMap.of("channel", "email", "text", "long message")); + + // Both sms_abuse and email_abuse evaluate text_risk, which would run the model twice if the + // shared decision were linked twice. + assertThat(result).isEqualTo("BLOCK"); + assertThat(modelInvocations.get()).isEqualTo(1); + } + + @Test + public void linkedDecision_optionalAndListOutputs() throws Exception { + String partial = + "name: partial\n" + + "rule:\n" + + " match:\n" + + " - condition: \"channel == 'sms'\"\n" + + " output: \"'SMS'\"\n"; + String aggregate = + "name: aggregate\n" + + "rule:\n" + + " aggregate:\n" + + " - condition: \"channel == 'sms'\"\n" + + " output: \"'sms_signal'\"\n" + + " - condition: \"size(text) > 3\"\n" + + " output: \"'long_text_signal'\"\n"; + String workflow = + "name: shapes\n" + + "rule:\n" + + " match:\n" + + " - condition: \"size(decisions.aggregate) == 2\"\n" + + " output: \"decisions.partial.orValue('NONE') + ':' + decisions.aggregate[1]\"\n" + + " - output: \"decisions.partial.orValue('NONE')\"\n"; + CelAbstractSyntaxTree ast = + compile( + workflow, + ImmutableMap.of( + "decisions.partial", compile(partial), + "decisions.aggregate", compile(aggregate))); + + Object result = eval(ast, ImmutableMap.of("channel", "sms", "text", "long message")); + + assertThat(ast.getResultType()).isEqualTo(SimpleType.STRING); + assertThat(result).isEqualTo("SMS:long_text_signal"); + } + + @Test + public void linkedDecision_mangledComprehensionsDoNotCollide() throws Exception { + String ints = + "name: ints\n" + + "rule:\n" + + " match:\n" + + " - condition: \"[size(text)].exists(x, x > 1) && [size(text)].exists(x, x > 1)\"\n" + + " output: \"true\"\n" + + " - output: \"false\"\n"; + String strings = + "name: strings\n" + + "rule:\n" + + " match:\n" + + " - condition: \"[channel].exists(x, x == 'sms') && [channel].exists(x, x ==" + + " 'sms')\"\n" + + " output: \"true\"\n" + + " - output: \"false\"\n"; + String workflow = + "name: comprehensions\n" + + "rule:\n" + + " match:\n" + + " - condition: \"decisions.ints && decisions.strings\"\n" + + " output: \"'BOTH'\"\n" + + " - output: \"'NOT_BOTH'\"\n"; + CelAbstractSyntaxTree ast = + compile( + workflow, + ImmutableMap.of( + "decisions.ints", compile(ints), "decisions.strings", compile(strings))); + + Object result = eval(ast, ImmutableMap.of("channel", "sms", "text", "long message")); + + assertThat(result).isEqualTo("BOTH"); + } + + @Test + public void linkedDecision_referencedWithinShadowingComprehension( + @TestParameter({ + "['SMS'].exists(channel, channel == decisions.channel)", + "cel.bind(channel, 'SMS', channel == decisions.channel)" + }) + String condition) + throws Exception { + String workflow = + "name: shadowing\n" + + "rule:\n" + + " match:\n" + + " - condition: \"" + + condition + + "\"\n" + + " output: \"'MATCH'\"\n" + + " - output: \"'NO_MATCH'\"\n"; + CelAbstractSyntaxTree ast = + compile(workflow, ImmutableMap.of("decisions.channel", compile(CHANNEL_POLICY))); + + Object result = eval(ast, ImmutableMap.of("channel", "sms", "text", "")); + + assertThat(result).isEqualTo("MATCH"); + } + + @Test + public void linkedDecision_unreferencedDecisionIsPruned() throws Exception { + String policy = + "name: unreferenced\n" + + "rule:\n" + + " match:\n" + + " - condition: \"channel == 'sms'\"\n" + + " output: \"'SMS'\"\n" + + " - output: \"'OTHER'\"\n"; + + CelAbstractSyntaxTree ast = + compile(policy, ImmutableMap.of("decisions.text_risk", compile(TEXT_RISK_POLICY))); + + assertThat(CelUnparserFactory.newUnparser().unparse(ast)) + .isEqualTo(CelUnparserFactory.newUnparser().unparse(compile(policy))); + } + + @Test + public void linkedDecision_mergedSizeExceedsIterationLimit_succeeds() throws Exception { + String workflow = + "name: workflow\n" + + "rule:\n" + + " match:\n" + + " - output: \"decisions.a || decisions.b || decisions.c\"\n"; + ImmutableMap.Builder decisions = ImmutableMap.builder(); + for (String name : ImmutableList.of("a", "b", "c")) { + decisions.put( + "decisions." + name, + compile( + String.format( + "name: %s\nrule:\n match:\n - output: \"[text, text, text, text, text, text]" + + " == ['%s']\"\n", + name, name))); + } + + // Each decision fits within the limit, but the linked AST does not. + CelAbstractSyntaxTree ast = + CelPolicyCompilerFactory.newPolicyCompiler(cel) + .setIterationLimit(10) + .addDecisions(decisions.buildOrThrow()) + .build() + .compile(POLICY_PARSER.parse(workflow)); + + assertThat(eval(ast, ImmutableMap.of("channel", "sms", "text", "a"))).isEqualTo(false); + } + + @Test + public void linkedDecision_selectOptimizerRunsAfterLinking() throws Exception { + String fieldPolicy = + "name: field\n" + + "rule:\n" + + " match:\n" + + " - condition: \"msg.single_int64 > 10\"\n" + + " output: \"'BIG'\"\n" + + " - output: \"'SMALL'\"\n"; + String workflow = + "name: workflow\n" + + "rule:\n" + + " match:\n" + + " - condition: \"decisions.field == 'BIG' && msg.single_string == 'x'\"\n" + + " output: \"'BLOCK'\"\n" + + " - output: \"'ALLOW'\"\n"; + + CelAbstractSyntaxTree ast = + CelPolicyCompilerFactory.newPolicyCompiler(cel) + .setOptimizers( + ImmutableList.of( + ConstantFoldingOptimizer.getInstance(), + SubexpressionOptimizer.getInstance(), + newSelectOptimizer())) + .addDecisions(ImmutableMap.of("decisions.field", compile(fieldPolicy))) + .build() + .compile(POLICY_PARSER.parse(workflow)); + + String unparsed = CelUnparserFactory.newUnparser().unparse(ast); + assertThat(unparsed).contains("\"single_int64\""); + assertThat(unparsed).contains("\"single_string\""); + assertThat(unparsed).doesNotContain("msg.single_"); + } + + @Test + public void linkedDecision_selectOptimizedDecision_throws() throws Exception { + CelAbstractSyntaxTree selectOptimized = + CelPolicyCompilerFactory.newPolicyCompiler(cel) + .setOptimizers(ImmutableList.of(newSelectOptimizer())) + .build() + .compile( + POLICY_PARSER.parse( + "name: field\n" + + "rule:\n" + + " match:\n" + + " - output: \"msg.single_int64 > 10\"\n")); + + CelPolicyCompiler compiler = newCompiler(ImmutableMap.of("decisions.field", selectOptimized)); + CelPolicy policy = + POLICY_PARSER.parse("name: workflow\nrule:\n match:\n - output: \"decisions.field\"\n"); + + CelPolicyValidationException e = + assertThrows(CelPolicyValidationException.class, () -> compiler.compile(policy)); + + assertThat(e).hasMessageThat().contains("undeclared reference to 'cel.@attribute'"); + } + + @Test + public void linkedDecision_unlinkedTransitiveDecision_throws() throws Exception { + CelAbstractSyntaxTree referencesB = + CelPolicyCompilerFactory.newPolicyCompiler( + cel.toCelBuilder().addVar("decisions.b", SimpleType.BOOL).build()) + .build() + .compile( + POLICY_PARSER.parse( + "name: a\nrule:\n match:\n - output: \"decisions.b && channel == 'sms'\"\n")); + + CelPolicyCompiler compiler = + newCompiler( + ImmutableMap.of( + "decisions.a", + referencesB, + "decisions.b", + compile("name: b\nrule:\n match:\n - output: \"true\"\n"))); + CelPolicy policy = + POLICY_PARSER.parse( + "name: workflow\nrule:\n match:\n - output: \"decisions.a && decisions.b\"\n"); + + CelPolicyValidationException e = + assertThrows(CelPolicyValidationException.class, () -> compiler.compile(policy)); + + assertThat(e) + .hasMessageThat() + .contains("Decision 'decisions.a' references unlinked decisions [decisions.b]"); + } + + @Test + public void linkedDecision_environmentMismatch_throws() throws Exception { + CelAbstractSyntaxTree extraDecision = + CelPolicyCompilerFactory.newPolicyCompiler( + cel.toCelBuilder().addVar("extra", SimpleType.INT).build()) + .build() + .compile( + POLICY_PARSER.parse("name: extra\nrule:\n match:\n - output: \"extra > 1\"\n")); + + CelPolicyCompiler compiler = newCompiler(ImmutableMap.of("decisions.extra", extraDecision)); + CelPolicy policy = + POLICY_PARSER.parse("name: workflow\nrule:\n match:\n - output: \"decisions.extra\"\n"); + + CelPolicyValidationException e = + assertThrows(CelPolicyValidationException.class, () -> compiler.compile(policy)); + + assertThat(e).hasMessageThat().contains("undeclared reference to 'extra'"); + } + + @Test + public void linkedDecision_typeMismatch_throws() throws Exception { + CelPolicyCompiler compiler = + newCompiler(ImmutableMap.of("decisions.channel", compile(CHANNEL_POLICY))); + CelPolicy policy = + POLICY_PARSER.parse( + "name: workflow\nrule:\n match:\n - output: \"decisions.channel + 1\"\n"); + + CelPolicyValidationException e = + assertThrows(CelPolicyValidationException.class, () -> compiler.compile(policy)); + + assertThat(e).hasMessageThat().contains("found no matching overload for '_+_'"); + } + + @Test + public void addDecisions_invalidName_throws( + @TestParameter({"channel", "@index0.x", "variables.foo"}) String name) throws Exception { + CelPolicyCompilerBuilder builder = + CelPolicyCompilerFactory.newPolicyCompiler(cel) + .addDecisions(ImmutableMap.of(name, compile(CHANNEL_POLICY))); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, builder::build); + + assertThat(e).hasMessageThat().contains("Invalid decision name"); + } + + @Test + public void addDecisions_uncheckedAst_throws() throws Exception { + CelPolicyCompilerBuilder builder = + CelPolicyCompilerFactory.newPolicyCompiler(cel) + .addDecisions( + ImmutableMap.of("decisions.parsed", cel.parse("channel == 'sms'").getAst())); + + assertThrows(IllegalArgumentException.class, builder::build); + } + + @Test + @SuppressWarnings("deprecation") // Exercises the fail-closed guard for a deprecated option. + public void addDecisions_namespacedDeclarationsDisabled_throws() throws Exception { + CelAbstractSyntaxTree decision = compile(CHANNEL_POLICY); + Cel nonNamespacedCel = + cel.toCelBuilder() + .setOptions( + CelOptions.current() + .enableHeterogeneousNumericComparisons(true) + .enableNamespacedDeclarations(false) + .build()) + .build(); + + CelPolicyCompilerBuilder builder = + CelPolicyCompilerFactory.newPolicyCompiler(nonNamespacedCel) + .addDecisions(ImmutableMap.of("decisions.channel", decision)); + + assertThrows(IllegalArgumentException.class, builder::build); + } + + private static SelectOptimizer newSelectOptimizer() { + return SelectOptimizer.newInstance( + SelectOptimizerOptions.newBuilder().build(), + ImmutableSet.of(TestAllTypes.getDescriptor().getFile())); + } + + private CelPolicyCompiler newCompiler(Map decisions) { + return CelPolicyCompilerFactory.newPolicyCompiler(cel).addDecisions(decisions).build(); + } + + private CelAbstractSyntaxTree compileWorkflow() throws Exception { + return compile( + WORKFLOW_POLICY, + ImmutableMap.of( + "decisions.channel", compile(CHANNEL_POLICY), + "decisions.text_risk", compile(TEXT_RISK_POLICY))); + } + + private CelAbstractSyntaxTree compile(String policy) throws Exception { + return compile(policy, ImmutableMap.of()); + } + + // Round-trips through CheckedExpr to mirror decisions loaded from separately built artifacts. + private CelAbstractSyntaxTree compile(String policy, Map decisions) + throws Exception { + CelAbstractSyntaxTree ast = newCompiler(decisions).compile(POLICY_PARSER.parse(policy)); + return CelProtoAbstractSyntaxTree.fromCheckedExpr( + CelProtoAbstractSyntaxTree.fromCelAst(ast).toCheckedExpr()) + .getAst(); + } + + private Object eval(CelAbstractSyntaxTree ast, Map input) throws Exception { + return cel.createProgram(ast).eval(input); + } +} diff --git a/policy/src/test/java/dev/cel/policy/tools/BUILD.bazel b/policy/src/test/java/dev/cel/policy/tools/BUILD.bazel index 8a3c55c40..10f30e648 100644 --- a/policy/src/test/java/dev/cel/policy/tools/BUILD.bazel +++ b/policy/src/test/java/dev/cel/policy/tools/BUILD.bazel @@ -22,6 +22,30 @@ compile_cel_policy( simple_variables = True, ) +# Example of module based evaluation: each sub-policy is compiled and owned separately, then +# statically linked into the deployable workflow as `decisions.*` identifiers. +compile_cel_policy( + name = "channel_decision", + config = "decisions/environment.yaml", + policy = "decisions/channel.celpolicy", +) + +compile_cel_policy( + name = "text_risk_decision", + config = "decisions/environment.yaml", + policy = "decisions/text_risk.celpolicy", +) + +compile_cel_policy( + name = "workflow", + config = "decisions/environment.yaml", + decisions = { + "decisions.channel": ":channel_decision", + "decisions.text_risk": ":text_risk_decision", + }, + policy = "decisions/workflow.celpolicy", +) + java_library( name = "tests", testonly = True, @@ -29,6 +53,7 @@ java_library( data = [ ":compiled_test_policy.binarypb", ":test_all_types_fds", + ":workflow.binarypb", "//testing/environment:proto3_message_variables", ], deps = [ @@ -39,6 +64,7 @@ java_library( "//extensions:optional_library", "//policy/tools", "//runtime", + "//runtime:function_binding", "@bazel_tools//tools/java/runfiles", "@cel_spec//proto/cel/expr:checked_java_proto", "@cel_spec//proto/cel/expr/conformance/proto3:test_all_types_java_proto", diff --git a/policy/src/test/java/dev/cel/policy/tools/CelPolicyCompilerToolTest.java b/policy/src/test/java/dev/cel/policy/tools/CelPolicyCompilerToolTest.java index 274dad626..7a3cb5900 100644 --- a/policy/src/test/java/dev/cel/policy/tools/CelPolicyCompilerToolTest.java +++ b/policy/src/test/java/dev/cel/policy/tools/CelPolicyCompilerToolTest.java @@ -18,6 +18,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import dev.cel.expr.CheckedExpr; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.io.Files; import com.google.devtools.build.runfiles.AutoBazelRepository; @@ -29,6 +30,7 @@ import dev.cel.common.CelSource; import dev.cel.expr.conformance.proto3.TestAllTypes; import dev.cel.extensions.CelOptionalLibrary; +import dev.cel.runtime.CelFunctionBinding; import dev.cel.runtime.CelRuntime; import dev.cel.runtime.CelRuntimeFactory; import java.io.ByteArrayOutputStream; @@ -550,6 +552,75 @@ public void compile_macroTarget_verifiedAndEvaluated() throws Exception { .isEqualTo("DENY"); } + @Test + public void compileCelPolicyMacro_linkedDecisions_success() throws Exception { + String rlocation = + "cel_java/policy/src/test/java/dev/cel/policy/tools/workflow.binarypb"; + CheckedExpr checkedExpr = + CheckedExpr.parseFrom( + Files.toByteArray(new File(resolveRunfile(rlocation))), + ExtensionRegistry.getEmptyRegistry()); + CelAbstractSyntaxTree ast = CelProtoAbstractSyntaxTree.fromCheckedExpr(checkedExpr).getAst(); + CelRuntime runtime = + CelRuntimeFactory.plannerRuntimeBuilder() + .addFunctionBindings( + CelFunctionBinding.from( + "RunTextModel_string", String.class, text -> ImmutableList.of(0.95, 0.1))) + .build(); + + Object result = + runtime.createProgram(ast).eval(ImmutableMap.of("channel", "sms", "text", "hi")); + + assertThat(result).isEqualTo("BLOCK"); + } + + @Test + public void compile_withDecision_success() throws Exception { + String configPath = + createFile("config.yaml", "name: test-env\nvariables:\n - name: age\n type: int\n"); + String decisionPolicyPath = + createFile("adult.yaml", "name: adult\nrule:\n match:\n - output: age >= 18\n"); + File decisionFile = tempFolder.newFile("adult.binarypb"); + new CommandLine(new CelPolicyCompilerTool()) + .execute( + "--policy", + decisionPolicyPath, + "--config", + configPath, + "--output", + decisionFile.getAbsolutePath()); + String policyPath = + createFile( + "policy.yaml", + "name: workflow\n" + + "rule:\n" + + " match:\n" + + " - condition: decisions.adult\n" + + " output: '\"allow\"'\n" + + " - output: '\"deny\"'\n"); + File outputFile = tempFolder.newFile("output.binarypb"); + + int exitCode = + new CommandLine(new CelPolicyCompilerTool()) + .execute( + "--policy", + policyPath, + "--config", + configPath, + "--decision", + "decisions.adult=" + decisionFile.getAbsolutePath(), + "--output", + outputFile.getAbsolutePath()); + + assertThat(exitCode).isEqualTo(0); + CelAbstractSyntaxTree ast = + CelProtoAbstractSyntaxTree.fromCheckedExpr( + CheckedExpr.parseFrom( + Files.toByteArray(outputFile), ExtensionRegistry.getEmptyRegistry())) + .getAst(); + assertThat(celRuntime.createProgram(ast).eval(ImmutableMap.of("age", 25L))).isEqualTo("allow"); + } + @Test public void compile_error_missingPolicy() throws Exception { String stdErr = executeExpectingError("--config", "foo.yaml", "--output", "out.binarypb"); diff --git a/policy/src/test/java/dev/cel/policy/tools/decisions/channel.celpolicy b/policy/src/test/java/dev/cel/policy/tools/decisions/channel.celpolicy new file mode 100644 index 000000000..600f31ff7 --- /dev/null +++ b/policy/src/test/java/dev/cel/policy/tools/decisions/channel.celpolicy @@ -0,0 +1,21 @@ +# 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. + +# Cheap precondition. +name: "channel" +rule: + match: + - condition: "channel in ['sms', 'rcs']" + output: "'MESSAGING'" + - output: "'UNKNOWN'" diff --git a/policy/src/test/java/dev/cel/policy/tools/decisions/environment.yaml b/policy/src/test/java/dev/cel/policy/tools/decisions/environment.yaml new file mode 100644 index 000000000..476b8ec6c --- /dev/null +++ b/policy/src/test/java/dev/cel/policy/tools/decisions/environment.yaml @@ -0,0 +1,30 @@ +# 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: "decisions_example" +variables: + - name: "channel" + type_name: "string" + - name: "text" + type_name: "string" +functions: + - name: "RunTextModel" + overloads: + - id: "RunTextModel_string" + args: + - type_name: "string" + return: + type_name: "list" + params: + - type_name: "double" diff --git a/policy/src/test/java/dev/cel/policy/tools/decisions/text_risk.celpolicy b/policy/src/test/java/dev/cel/policy/tools/decisions/text_risk.celpolicy new file mode 100644 index 000000000..5c9c72a09 --- /dev/null +++ b/policy/src/test/java/dev/cel/policy/tools/decisions/text_risk.celpolicy @@ -0,0 +1,24 @@ +# 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. + +# Expensive sub-decision backed by an ML model. +name: "text_risk" +rule: + variables: + - name: "scores" + expression: "RunTextModel(text)" + match: + - condition: "variables.scores.exists(score, score >= 0.9)" + output: "'HIGH'" + - output: "'LOW'" diff --git a/policy/src/test/java/dev/cel/policy/tools/decisions/workflow.celpolicy b/policy/src/test/java/dev/cel/policy/tools/decisions/workflow.celpolicy new file mode 100644 index 000000000..11136d855 --- /dev/null +++ b/policy/src/test/java/dev/cel/policy/tools/decisions/workflow.celpolicy @@ -0,0 +1,24 @@ +# 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. + +# Deployable workflow. The sub-policies are linked in BUILD; text_risk is only evaluated when the +# channel precondition passes, and at most once. +name: "workflow" +rule: + match: + - condition: "decisions.channel == 'UNKNOWN'" + output: "'IGNORE'" + - condition: "decisions.text_risk == 'HIGH'" + output: "'BLOCK'" + - output: "'ALLOW'" diff --git a/policy/tools/compile_cel_policy.bzl b/policy/tools/compile_cel_policy.bzl index 7574c251f..b8372220f 100644 --- a/policy/tools/compile_cel_policy.bzl +++ b/policy/tools/compile_cel_policy.bzl @@ -28,6 +28,7 @@ def compile_cel_policy( optimize_field_selection = False, simple_variables = False, iteration_limit = None, + decisions = {}, visibility = None): """Compiles a CEL policy into a CheckedExpr binarypb or textpb with optional select optimization. @@ -54,6 +55,9 @@ def compile_cel_policy( `- var_name: expr` instead of `- name: var_name` / `expression: expr`) in the policy (default False) iteration_limit: (optional) int maximum iteration limit for composing and optimizing the policy (default None, which uses the compiler default of 1000) + decisions: (optional) dict mapping an identifier referenced by the policy (e.g. + "decisions.channel") to a binarypb compile_cel_policy target to statically link. Linked + targets must not set optimize_field_selection. visibility: (optional) visibility to use on the genrule macro (default None) """ if output_format not in ("binarypb", "textpb", "textproto"): @@ -68,11 +72,11 @@ def compile_cel_policy( args = [] srcs = [policy, config] - args.append("--policy=$(location %s)" % policy) - args.append("--config=$(location %s)" % config) + args.append("--policy=$(execpath %s)" % policy) + args.append("--config=$(execpath %s)" % config) if base_config != None: - args.append("--base_config=$(location %s)" % base_config) + args.append("--base_config=$(execpath %s)" % base_config) srcs.append(base_config) if len(proto_srcs) > 0 and file_descriptor_set != None: @@ -87,10 +91,10 @@ def compile_cel_policy( file_descriptor_set = transitive_descriptor_set_name if file_descriptor_set != None: - args.append("--file_descriptor_set=$(location %s)" % file_descriptor_set) + args.append("--file_descriptor_set=$(execpath %s)" % file_descriptor_set) srcs.append(file_descriptor_set) - args.append("--output=$(location %s)" % output) + args.append("--output=$(execpath %s)" % output) args.append("--output_format=" + output_format) args.append("--output_version=" + output_version) @@ -103,8 +107,12 @@ def compile_cel_policy( if iteration_limit != None: args.append("--iteration_limit=%d" % iteration_limit) + for identifier, decision in decisions.items(): + args.append("--decision=%s=$(execpath %s)" % (identifier, decision)) + srcs.append(decision) + cmd = ( - "$(location //policy/tools:cel_policy_compiler_tool) " + + "$(execpath //policy/tools:cel_policy_compiler_tool) " + " ".join(args) )