Skip to content

Commit 6ec1779

Browse files
l46kokcopybara-github
authored andcommitted
Implement custom policy invariants verification
Enables policy authors to declare custom logical invariants (`assume` preconditions and `assert` clauses) on `CelPolicy` definitions, mathematically verifying that properties hold across all possible input states. PiperOrigin-RevId: 915170572
1 parent ff7bccf commit 6ec1779

27 files changed

Lines changed: 1295 additions & 149 deletions

policy/src/main/java/dev/cel/policy/CelPolicy.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ public abstract class CelPolicy {
5353

5454
public abstract ImmutableList<Import> imports();
5555

56+
public abstract ImmutableList<Invariant> invariants();
57+
5658
/** Creates a new builder to construct a {@link CelPolicy} instance. */
5759
public static Builder newBuilder() {
5860
return new AutoValue_CelPolicy.Builder()
@@ -90,6 +92,14 @@ public List<Import> imports() {
9092
return Collections.unmodifiableList(importList);
9193
}
9294

95+
private final ArrayList<Invariant> invariantList = new ArrayList<>();
96+
97+
abstract Builder setInvariants(ImmutableList<Invariant> value);
98+
99+
public List<Invariant> invariants() {
100+
return Collections.unmodifiableList(invariantList);
101+
}
102+
93103
public Map<String, Object> metadata() {
94104
return Collections.unmodifiableMap(metadata);
95105
}
@@ -106,6 +116,18 @@ public Builder addImports(Collection<Import> values) {
106116
return this;
107117
}
108118

119+
@CanIgnoreReturnValue
120+
public Builder addInvariant(Invariant value) {
121+
invariantList.add(value);
122+
return this;
123+
}
124+
125+
@CanIgnoreReturnValue
126+
public Builder addInvariants(Collection<Invariant> values) {
127+
invariantList.addAll(values);
128+
return this;
129+
}
130+
109131
@CanIgnoreReturnValue
110132
public Builder putMetadata(String key, Object value) {
111133
metadata.put(key, value);
@@ -122,6 +144,7 @@ public Builder putMetadata(Map<String, Object> map) {
122144

123145
public CelPolicy build() {
124146
setImports(ImmutableList.copyOf(importList));
147+
setInvariants(ImmutableList.copyOf(invariantList));
125148
setMetadata(ImmutableMap.copyOf(metadata));
126149
return autoBuild();
127150
}
@@ -328,4 +351,52 @@ public static Import create(long id, ValueString name) {
328351
return new AutoValue_CelPolicy_Import(id, name);
329352
}
330353
}
354+
355+
/**
356+
* Invariant declares a required logical property that must hold true under specified
357+
* preconditions.
358+
*/
359+
@AutoValue
360+
public abstract static class Invariant {
361+
public abstract long id();
362+
363+
public abstract ValueString invariantId();
364+
365+
public abstract Optional<ValueString> description();
366+
367+
public abstract Optional<ValueString> assume();
368+
369+
public abstract ValueString assertClause();
370+
371+
/** Builder for {@link Invariant}. */
372+
@AutoValue.Builder
373+
public abstract static class Builder implements RequiredFieldsChecker {
374+
public abstract Builder setId(long value);
375+
376+
abstract Optional<ValueString> invariantId();
377+
378+
abstract Optional<ValueString> assertClause();
379+
380+
public abstract Builder setInvariantId(ValueString value);
381+
382+
public abstract Builder setDescription(ValueString value);
383+
384+
public abstract Builder setAssume(ValueString value);
385+
386+
public abstract Builder setAssertClause(ValueString value);
387+
388+
@Override
389+
public ImmutableList<RequiredField> requiredFields() {
390+
return ImmutableList.of(
391+
RequiredField.of("id", this::invariantId),
392+
RequiredField.of("assert", this::assertClause));
393+
}
394+
395+
public abstract Invariant build();
396+
}
397+
398+
public static Builder newBuilder(long id) {
399+
return new AutoValue_CelPolicy_Invariant.Builder().setId(id);
400+
}
401+
}
331402
}

policy/src/main/java/dev/cel/policy/CelPolicyYamlParser.java

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import dev.cel.common.formats.YamlParserContextImpl;
2929
import dev.cel.common.internal.CelCodePointArray;
3030
import dev.cel.policy.CelPolicy.Import;
31+
import dev.cel.policy.CelPolicy.Invariant;
3132
import dev.cel.policy.CelPolicy.Match;
3233
import dev.cel.policy.CelPolicy.Match.Result;
3334
import dev.cel.policy.CelPolicy.Variable;
@@ -47,6 +48,8 @@ final class CelPolicyYamlParser implements CelPolicyParser {
4748
Match.newBuilder(0).setCondition(ERROR_VALUE).setResult(Result.ofOutput(ERROR_VALUE)).build();
4849
private static final Variable ERROR_VARIABLE =
4950
Variable.newBuilder().setExpression(ERROR_VALUE).setName(ERROR_VALUE).build();
51+
private static final Invariant ERROR_INVARIANT =
52+
Invariant.newBuilder(0).setInvariantId(ERROR_VALUE).setAssertClause(ERROR_VALUE).build();
5053

5154
private final TagVisitor<Node> tagVisitor;
5255
private final boolean enableSimpleVariables;
@@ -137,6 +140,9 @@ public CelPolicy parsePolicy(PolicyParserContext<Node> ctx, Node node) {
137140
case "rule":
138141
policyBuilder.setRule(parseRule(ctx, policyBuilder, valueNode));
139142
break;
143+
case "verification":
144+
parseVerification(policyBuilder, ctx, valueNode);
145+
break;
140146
default:
141147
tagVisitor.visitPolicyTag(ctx, keyId, fieldName, valueNode, policyBuilder);
142148
break;
@@ -148,6 +154,36 @@ public CelPolicy parsePolicy(PolicyParserContext<Node> ctx, Node node) {
148154
.build();
149155
}
150156

157+
private void parseVerification(
158+
CelPolicy.Builder policyBuilder, PolicyParserContext<Node> ctx, Node node) {
159+
long id = ctx.collectMetadata(node);
160+
if (!assertYamlType(ctx, id, node, YamlNodeType.MAP)) {
161+
return;
162+
}
163+
MappingNode mappingNode = (MappingNode) node;
164+
for (NodeTuple nodeTuple : mappingNode.getValue()) {
165+
Node key = nodeTuple.getKeyNode();
166+
long keyId = ctx.collectMetadata(key);
167+
if (!assertYamlType(ctx, keyId, key, YamlNodeType.STRING, YamlNodeType.TEXT)) {
168+
continue;
169+
}
170+
String fieldName = ((ScalarNode) key).getValue();
171+
Node valueNode = nodeTuple.getValueNode();
172+
if (fieldName.equals("invariants")) {
173+
long valueId = ctx.collectMetadata(valueNode);
174+
if (!assertYamlType(ctx, valueId, valueNode, YamlNodeType.LIST)) {
175+
continue;
176+
}
177+
SequenceNode invariantListNode = (SequenceNode) valueNode;
178+
for (Node invariantNode : invariantListNode.getValue()) {
179+
policyBuilder.addInvariant(parseInvariant(ctx, policyBuilder, invariantNode));
180+
}
181+
} else {
182+
ctx.reportError(keyId, "Unexpected key in verification block: " + fieldName);
183+
}
184+
}
185+
}
186+
151187
private void parseImports(
152188
CelPolicy.Builder policyBuilder, PolicyParserContext<Node> ctx, Node node) {
153189
long id = ctx.collectMetadata(node);
@@ -409,6 +445,50 @@ private Variable parseVariableObject(
409445
return builder.build();
410446
}
411447

448+
@Override
449+
public CelPolicy.Invariant parseInvariant(
450+
PolicyParserContext<Node> ctx, CelPolicy.Builder policyBuilder, Node node) {
451+
long id = ctx.collectMetadata(node);
452+
Invariant.Builder builder = Invariant.newBuilder(id);
453+
if (!assertYamlType(ctx, id, node, YamlNodeType.MAP)) {
454+
return ERROR_INVARIANT;
455+
}
456+
457+
MappingNode invariantMap = (MappingNode) node;
458+
for (NodeTuple nodeTuple : invariantMap.getValue()) {
459+
Node keyNode = nodeTuple.getKeyNode();
460+
long keyId = ctx.collectMetadata(keyNode);
461+
if (!assertYamlType(ctx, keyId, keyNode, YamlNodeType.STRING, YamlNodeType.TEXT)) {
462+
continue;
463+
}
464+
Node valueNode = nodeTuple.getValueNode();
465+
String keyName = ((ScalarNode) keyNode).getValue();
466+
switch (keyName) {
467+
case "id":
468+
builder.setInvariantId(ctx.newYamlString(valueNode));
469+
break;
470+
case "description":
471+
builder.setDescription(ctx.newYamlString(valueNode));
472+
break;
473+
case "assume":
474+
builder.setAssume(ctx.newSourceString(valueNode));
475+
break;
476+
case "assert":
477+
builder.setAssertClause(ctx.newSourceString(valueNode));
478+
break;
479+
default:
480+
ctx.reportError(keyId, "Unexpected key in invariant block: " + keyName);
481+
break;
482+
}
483+
}
484+
485+
if (!assertRequiredFields(ctx, id, builder.getMissingRequiredFieldNames())) {
486+
return ERROR_INVARIANT;
487+
}
488+
489+
return builder.build();
490+
}
491+
412492
private ParserImpl(
413493
TagVisitor<Node> tagVisitor,
414494
boolean enableSimpleVariables,

policy/src/main/java/dev/cel/policy/PolicyParserContext.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import com.google.auto.value.AutoValue;
1818
import dev.cel.common.formats.ParserContext;
19+
import dev.cel.policy.CelPolicy.Invariant;
1920
import dev.cel.policy.CelPolicy.Match;
2021
import dev.cel.policy.CelPolicy.Rule;
2122
import dev.cel.policy.CelPolicy.Variable;
@@ -51,4 +52,6 @@ static NewPolicyMetadata create(CelPolicySource source, long id) {
5152
Match parseMatch(PolicyParserContext<T> ctx, CelPolicy.Builder policyBuilder, T node);
5253

5354
Variable parseVariable(PolicyParserContext<T> ctx, CelPolicy.Builder policyBuilder, T node);
55+
56+
Invariant parseInvariant(PolicyParserContext<T> ctx, CelPolicy.Builder policyBuilder, T node);
5457
}

policy/src/test/java/dev/cel/policy/BUILD.bazel

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@ package(
88
java_library(
99
name = "tests",
1010
testonly = True,
11-
srcs = glob(
12-
["*.java"],
13-
),
11+
srcs = glob(["*.java"]),
1412
data = [
1513
"@cel_policy//conformance:testdata",
1614
],

policy/src/test/java/dev/cel/policy/CelPolicyYamlParserTest.java

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717
import static com.google.common.truth.Truth.assertThat;
1818
import static org.junit.Assert.assertThrows;
1919

20+
import com.google.common.collect.ImmutableList;
2021
import com.google.common.collect.Iterables;
2122
import com.google.testing.junit.testparameterinjector.TestParameter;
2223
import com.google.testing.junit.testparameterinjector.TestParameterInjector;
2324
import dev.cel.common.formats.ValueString;
2425
import dev.cel.policy.CelPolicy.Import;
26+
import dev.cel.policy.CelPolicy.Invariant;
2527
import dev.cel.policy.PolicyTestHelper.TestYamlPolicy;
2628
import dev.cel.policy.testing.K8sTagHandler;
2729
import org.junit.Test;
@@ -194,6 +196,43 @@ public void parseYamlPolicy_errors(@TestParameter PolicyParseErrorTestCase testC
194196
assertThat(e).hasMessageThat().isEqualTo(testCase.expectedErrorMessage);
195197
}
196198

199+
@Test
200+
public void policyBuilder_addInvariants() {
201+
Invariant invariant =
202+
Invariant.newBuilder(1L)
203+
.setInvariantId(ValueString.newBuilder().setValue("id").build())
204+
.setAssertClause(ValueString.newBuilder().setValue("true").build())
205+
.build();
206+
CelPolicy policy =
207+
CelPolicy.newBuilder()
208+
.setName(ValueString.of(0, "test"))
209+
.setPolicySource(CelPolicySource.newBuilder("").build())
210+
.addInvariants(ImmutableList.of(invariant))
211+
.build();
212+
assertThat(policy.invariants()).containsExactly(invariant);
213+
}
214+
215+
@Test
216+
public void parseYamlPolicy_invariants_success() throws Exception {
217+
String policySource =
218+
"name: 'policy_with_invariants'\n"
219+
+ "verification:\n"
220+
+ " invariants:\n"
221+
+ " - id: 'inv_1'\n"
222+
+ " description: 'invariant description'\n"
223+
+ " assume: 'true'\n"
224+
+ " assert: 'invariants.result == true'";
225+
226+
CelPolicy policy = POLICY_PARSER.parse(policySource);
227+
228+
assertThat(policy.invariants()).hasSize(1);
229+
Invariant invariant = Iterables.getOnlyElement(policy.invariants());
230+
assertThat(invariant.invariantId().value()).isEqualTo("inv_1");
231+
assertThat(invariant.description().get().value()).isEqualTo("invariant description");
232+
assertThat(invariant.assume().get().value()).isEqualTo("true");
233+
assertThat(invariant.assertClause().value()).isEqualTo("invariants.result == true");
234+
}
235+
197236
private enum PolicyParseErrorTestCase {
198237
MALFORMED_YAML_DOCUMENT(
199238
"a:\na",
@@ -400,7 +439,70 @@ private enum PolicyParseErrorTestCase {
400439
+ "- foo: bar",
401440
"ERROR: <input>:2:3: Invalid import key: foo, expected 'name'\n"
402441
+ " | - foo: bar\n"
403-
+ " | ..^");
442+
+ " | ..^"),
443+
UNSUPPORTED_VERIFICATION_TAG(
444+
"verification:\n" //
445+
+ " bad_key: true",
446+
"ERROR: <input>:2:3: Unexpected key in verification block: bad_key\n"
447+
+ " | bad_key: true\n"
448+
+ " | ..^"),
449+
UNSUPPORTED_INVARIANT_TAG(
450+
"verification:\n" //
451+
+ " invariants:\n" //
452+
+ " - id: foo\n" //
453+
+ " bad_inv_key: true\n" //
454+
+ " assert: 'true'",
455+
"ERROR: <input>:4:7: Unexpected key in invariant block: bad_inv_key\n"
456+
+ " | bad_inv_key: true\n"
457+
+ " | ......^"),
458+
MISSING_INVARIANT_ID(
459+
"verification:\n" //
460+
+ " invariants:\n" //
461+
+ " - assert: 'true'",
462+
"ERROR: <input>:3:7: Missing required attribute(s): id\n"
463+
+ " | - assert: 'true'\n"
464+
+ " | ......^"),
465+
MISSING_INVARIANT_ASSERT(
466+
"verification:\n" //
467+
+ " invariants:\n" //
468+
+ " - id: foo",
469+
"ERROR: <input>:3:7: Missing required attribute(s): assert\n"
470+
+ " | - id: foo\n"
471+
+ " | ......^"),
472+
ILLEGAL_YAML_TYPE_ON_VERIFICATION_VALUE(
473+
"verification: illegal\n",
474+
"ERROR: <input>:1:15: Got yaml node type tag:yaml.org,2002:str, wanted type(s)"
475+
+ " [tag:yaml.org,2002:map]\n"
476+
+ " | verification: illegal\n"
477+
+ " | ..............^"),
478+
ILLEGAL_YAML_TYPE_ON_VERIFICATION_MAP_KEY(
479+
"verification:\n" + " 1: foo",
480+
"ERROR: <input>:2:3: Got yaml node type tag:yaml.org,2002:int, wanted type(s)"
481+
+ " [tag:yaml.org,2002:str !txt]\n"
482+
+ " | 1: foo\n"
483+
+ " | ..^"),
484+
ILLEGAL_YAML_TYPE_ON_INVARIANTS_VALUE(
485+
"verification:\n" + " invariants: illegal\n",
486+
"ERROR: <input>:2:15: Got yaml node type tag:yaml.org,2002:str, wanted type(s)"
487+
+ " [tag:yaml.org,2002:seq]\n"
488+
+ " | invariants: illegal\n"
489+
+ " | ..............^"),
490+
ILLEGAL_YAML_TYPE_ON_INVARIANTS_LIST(
491+
"verification:\n" + " invariants:\n" + " - illegal",
492+
"ERROR: <input>:3:7: Got yaml node type tag:yaml.org,2002:str, wanted type(s)"
493+
+ " [tag:yaml.org,2002:map]\n"
494+
+ " | - illegal\n"
495+
+ " | ......^"),
496+
ILLEGAL_YAML_TYPE_ON_INVARIANT_MAP_KEY(
497+
"verification:\n"
498+
+ " invariants:\n"
499+
+ " - 1: foo\n"
500+
+ " id: 'hi'\n"
501+
+ " assert: 'true'",
502+
"ERROR: <input>:3:7: Got yaml node type tag:yaml.org,2002:int, wanted type(s)"
503+
+ " [tag:yaml.org,2002:str !txt]\n"
504+
+ " | - 1: foo\n"
505+
+ " | ......^");
404506

405507
private final String yamlPolicy;
406508
private final String expectedErrorMessage;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
name: flawed_policy
16+
rule:
17+
match:
18+
- condition: port == 80
19+
output: 'true'
20+
- output: 'false'
21+
verification:
22+
invariants:
23+
- id: always_secure
24+
assert: invariants.result == false

0 commit comments

Comments
 (0)