Skip to content

Commit 30d35bf

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 30d35bf

28 files changed

Lines changed: 1193 additions & 146 deletions

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ java_library(
248248
":compiled_rule",
249249
"//bundle:cel",
250250
"//common:cel_ast",
251+
"//common:cel_source",
251252
"//common:compiler_common",
252253
"//common:mutable_ast",
253254
"//common:mutable_source",

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: 77 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,47 @@ 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+
Node valueNode = nodeTuple.getValueNode();
462+
String keyName = ((ScalarNode) keyNode).getValue();
463+
switch (keyName) {
464+
case "id":
465+
builder.setInvariantId(ctx.newYamlString(valueNode));
466+
break;
467+
case "description":
468+
builder.setDescription(ctx.newYamlString(valueNode));
469+
break;
470+
case "assume":
471+
builder.setAssume(ctx.newSourceString(valueNode));
472+
break;
473+
case "assert":
474+
builder.setAssertClause(ctx.newSourceString(valueNode));
475+
break;
476+
default:
477+
ctx.reportError(keyId, "Unexpected key in invariant block: " + keyName);
478+
break;
479+
}
480+
}
481+
482+
if (!assertRequiredFields(ctx, id, builder.getMissingRequiredFieldNames())) {
483+
return ERROR_INVARIANT;
484+
}
485+
486+
return builder.build();
487+
}
488+
412489
private ParserImpl(
413490
TagVisitor<Node> tagVisitor,
414491
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: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,36 @@ private enum PolicyParseErrorTestCase {
400400
+ "- foo: bar",
401401
"ERROR: <input>:2:3: Invalid import key: foo, expected 'name'\n"
402402
+ " | - foo: bar\n"
403-
+ " | ..^");
403+
+ " | ..^"),
404+
UNSUPPORTED_VERIFICATION_TAG(
405+
"verification:\n" //
406+
+ " bad_key: true",
407+
"ERROR: <input>:2:3: Unexpected key in verification block: bad_key\n"
408+
+ " | bad_key: true\n"
409+
+ " | ..^"),
410+
UNSUPPORTED_INVARIANT_TAG(
411+
"verification:\n" //
412+
+ " invariants:\n" //
413+
+ " - id: foo\n" //
414+
+ " bad_inv_key: true\n" //
415+
+ " assert: 'true'",
416+
"ERROR: <input>:4:7: Unexpected key in invariant block: bad_inv_key\n"
417+
+ " | bad_inv_key: true\n"
418+
+ " | ......^"),
419+
MISSING_INVARIANT_ID(
420+
"verification:\n" //
421+
+ " invariants:\n" //
422+
+ " - assert: 'true'",
423+
"ERROR: <input>:3:7: Missing required attribute(s): id\n"
424+
+ " | - assert: 'true'\n"
425+
+ " | ......^"),
426+
MISSING_INVARIANT_ASSERT(
427+
"verification:\n" //
428+
+ " invariants:\n" //
429+
+ " - id: foo",
430+
"ERROR: <input>:3:7: Missing required attribute(s): assert\n"
431+
+ " | - id: foo\n"
432+
+ " | ......^");
404433

405434
private final String yamlPolicy;
406435
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
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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: multi_invariant_policy
16+
rule:
17+
match:
18+
- condition: role == 'admin' || role == 'editor'
19+
output: 'true'
20+
- output: 'false'
21+
verification:
22+
invariants:
23+
- id: admin_granted
24+
assume: role == 'admin'
25+
assert: invariants.result == true
26+
- id: viewer_never_granted
27+
assume: role == 'viewer'
28+
assert: invariants.result == true
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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: secure_resource_access
16+
rule:
17+
variables:
18+
- is_admin: "'admin' in test_all_types.repeated_string"
19+
- is_break_glass: "test_all_types.single_string != ''"
20+
match:
21+
- condition: variables.is_admin && variables.is_break_glass
22+
output: 'true'
23+
- output: 'false'
24+
verification:
25+
invariants:
26+
- id: no_unprivileged_break_glass
27+
assume: "!('admin' in test_all_types.repeated_string)"
28+
assert: invariants.result == false

0 commit comments

Comments
 (0)