Skip to content

Commit e50e3cd

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 e50e3cd

27 files changed

Lines changed: 1315 additions & 149 deletions

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

Lines changed: 60 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,10 @@ public List<Import> imports() {
9092
return Collections.unmodifiableList(importList);
9193
}
9294

95+
abstract ImmutableList<Invariant> invariants();
96+
97+
abstract ImmutableList.Builder<Invariant> invariantsBuilder();
98+
9399
public Map<String, Object> metadata() {
94100
return Collections.unmodifiableMap(metadata);
95101
}
@@ -106,6 +112,12 @@ public Builder addImports(Collection<Import> values) {
106112
return this;
107113
}
108114

115+
@CanIgnoreReturnValue
116+
public Builder addInvariant(Invariant value) {
117+
invariantsBuilder().add(value);
118+
return this;
119+
}
120+
109121
@CanIgnoreReturnValue
110122
public Builder putMetadata(String key, Object value) {
111123
metadata.put(key, value);
@@ -328,4 +340,52 @@ public static Import create(long id, ValueString name) {
328340
return new AutoValue_CelPolicy_Import(id, name);
329341
}
330342
}
343+
344+
/**
345+
* Invariant declares a required logical property that must hold true under specified
346+
* preconditions.
347+
*/
348+
@AutoValue
349+
public abstract static class Invariant {
350+
public abstract long id();
351+
352+
public abstract ValueString invariantId();
353+
354+
public abstract Optional<ValueString> description();
355+
356+
public abstract Optional<ValueString> assume();
357+
358+
public abstract ValueString assertClause();
359+
360+
/** Builder for {@link Invariant}. */
361+
@AutoValue.Builder
362+
public abstract static class Builder implements RequiredFieldsChecker {
363+
public abstract Builder setId(long value);
364+
365+
abstract Optional<ValueString> invariantId();
366+
367+
abstract Optional<ValueString> assertClause();
368+
369+
public abstract Builder setInvariantId(ValueString value);
370+
371+
public abstract Builder setDescription(ValueString value);
372+
373+
public abstract Builder setAssume(ValueString value);
374+
375+
public abstract Builder setAssertClause(ValueString value);
376+
377+
@Override
378+
public ImmutableList<RequiredField> requiredFields() {
379+
return ImmutableList.of(
380+
RequiredField.of("id", this::invariantId),
381+
RequiredField.of("assert", this::assertClause));
382+
}
383+
384+
public abstract Invariant build();
385+
}
386+
387+
public static Builder newBuilder(long id) {
388+
return new AutoValue_CelPolicy_Invariant.Builder().setId(id);
389+
}
390+
}
331391
}

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: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import com.google.testing.junit.testparameterinjector.TestParameterInjector;
2323
import dev.cel.common.formats.ValueString;
2424
import dev.cel.policy.CelPolicy.Import;
25+
import dev.cel.policy.CelPolicy.Invariant;
2526
import dev.cel.policy.PolicyTestHelper.TestYamlPolicy;
2627
import dev.cel.policy.testing.K8sTagHandler;
2728
import org.junit.Test;
@@ -194,6 +195,43 @@ public void parseYamlPolicy_errors(@TestParameter PolicyParseErrorTestCase testC
194195
assertThat(e).hasMessageThat().isEqualTo(testCase.expectedErrorMessage);
195196
}
196197

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

405506
private final String yamlPolicy;
406507
private final String expectedErrorMessage;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
description: Tests detecting an invariant violation when a policy allows insecure output (port == 80), checking accurate counterexample generation.
17+
rule:
18+
match:
19+
- condition: port == 80
20+
output: 'true'
21+
- output: 'false'
22+
verification:
23+
invariants:
24+
- id: always_secure
25+
description: Asserts that insecure output is never allowed, producing a counterexample when port is 80.
26+
assert: invariants.result == false

0 commit comments

Comments
 (0)