Skip to content

Commit a9d2ed5

Browse files
Partial schema validation (#79)
Experimental implementation of partial schema validation as described in cedar-policy/rfcs#8.
1 parent 785b9c7 commit a9d2ed5

31 files changed

+1411
-323
lines changed

cedar-policy-cli/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ serde_json = "1.0"
1919
miette = { version = "5.9.0", features = ["fancy"] }
2020
thiserror = "1.0"
2121

22+
[features]
23+
default = []
24+
experimental = ["partial-validate"]
25+
partial-validate = ["cedar-policy/partial-validate"]
26+
2227
[dev-dependencies]
2328
assert_cmd = "2.0"
2429
tempfile = "3"

cedar-policy-cli/src/lib.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ pub struct ValidateArgs {
111111
/// Report a validation failure for non-fatal warnings
112112
#[arg(long)]
113113
pub deny_warnings: bool,
114+
/// Validate the policy using partial schema validation. This option is
115+
/// experimental and will cause the CLI to exit if it was not built with the
116+
/// experimental `partial-validate` feature enabled.
117+
#[arg(long = "partial-validate")]
118+
pub partial_validate: bool,
114119
}
115120

116121
#[derive(Args, Debug)]
@@ -432,6 +437,18 @@ pub fn check_parse(args: &CheckParseArgs) -> CedarExitCode {
432437
}
433438

434439
pub fn validate(args: &ValidateArgs) -> CedarExitCode {
440+
let mode = if args.partial_validate {
441+
#[cfg(not(feature = "partial-validate"))]
442+
{
443+
println!("Error: arguments include the experimental option `--partial-validate`, but this executable was not built with `partial-validate` experimental feature enabled");
444+
return CedarExitCode::Failure;
445+
}
446+
#[cfg(feature = "partial-validate")]
447+
ValidationMode::Partial
448+
} else {
449+
ValidationMode::default()
450+
};
451+
435452
let pset = match read_policy_set(Some(&args.policies_file)) {
436453
Ok(pset) => pset,
437454
Err(e) => {
@@ -449,7 +466,7 @@ pub fn validate(args: &ValidateArgs) -> CedarExitCode {
449466
};
450467

451468
let validator = Validator::new(schema);
452-
let result = validator.validate(&pset, ValidationMode::default());
469+
let result = validator.validate(&pset, mode);
453470

454471
let exit_code = if !result.validation_passed()
455472
|| (args.deny_warnings && !result.validation_passed_without_warnings())

cedar-policy-cli/tests/sample.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ fn run_validate_test(policies_file: &str, schema_file: &str, exit_code: CedarExi
446446
schema_file: schema_file.into(),
447447
policies_file: policies_file.into(),
448448
deny_warnings: false,
449+
partial_validate: false,
449450
};
450451
let output = validate(&cmd);
451452
assert_eq!(exit_code, output, "{:#?}", cmd);

cedar-policy-core/src/entities.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,11 +1767,13 @@ mod schema_based_parsing_tests {
17671767
)]
17681768
.into_iter()
17691769
.collect(),
1770+
open_attrs: false,
17701771
}),
17711772
),
17721773
]
17731774
.into_iter()
17741775
.collect(),
1776+
open_attrs: false,
17751777
}),
17761778
"home_ip" => Some(SchemaType::Extension {
17771779
name: Name::parse_unqualified_name("ipaddr").expect("valid"),
@@ -1789,6 +1791,7 @@ mod schema_based_parsing_tests {
17891791
]
17901792
.into_iter()
17911793
.collect(),
1794+
open_attrs: false,
17921795
}),
17931796
_ => None,
17941797
}
@@ -1815,6 +1818,10 @@ mod schema_based_parsing_tests {
18151818
fn allowed_parent_types(&self) -> Arc<HashSet<EntityType>> {
18161819
Arc::new(HashSet::new())
18171820
}
1821+
1822+
fn open_attributes(&self) -> bool {
1823+
false
1824+
}
18181825
}
18191826

18201827
#[cfg(all(feature = "decimal", feature = "ipaddr"))]
@@ -2878,6 +2885,10 @@ mod schema_based_parsing_tests {
28782885
fn allowed_parent_types(&self) -> Arc<HashSet<EntityType>> {
28792886
Arc::new(HashSet::new())
28802887
}
2888+
2889+
fn open_attributes(&self) -> bool {
2890+
false
2891+
}
28812892
}
28822893

28832894
let entitiesjson = json!(

cedar-policy-core/src/entities/conformance.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,12 @@ impl<'a, S: Schema> EntitySchemaConformanceChecker<'a, S> {
167167
None => {
168168
// `None` indicates the attribute shouldn't exist -- see
169169
// docs on the `attr_type()` trait method
170-
return Err(EntitySchemaConformanceError::UnexpectedEntityAttr {
171-
uid: uid.clone(),
172-
attr: attr.clone(),
173-
});
170+
if !schema_etype.open_attributes() {
171+
return Err(EntitySchemaConformanceError::UnexpectedEntityAttr {
172+
uid: uid.clone(),
173+
attr: attr.clone(),
174+
});
175+
}
174176
}
175177
Some(expected_ty) => {
176178
// typecheck: ensure that the entity attribute value matches

cedar-policy-core/src/entities/json/context.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ impl ContextSchema for NullContextSchema {
3333
fn context_type(&self) -> SchemaType {
3434
SchemaType::Record {
3535
attrs: HashMap::new(),
36+
open_attrs: false,
3637
}
3738
}
3839
}

cedar-policy-core/src/entities/json/entities.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,12 +274,21 @@ impl<'e, 's, S: Schema> EntityJsonParser<'e, 's, S> {
274274
// `None` indicates the attribute shouldn't exist -- see
275275
// docs on the `attr_type()` trait method
276276
None => {
277-
return Err(JsonDeserializationError::EntitySchemaConformance(
278-
EntitySchemaConformanceError::UnexpectedEntityAttr {
279-
uid: uid.clone(),
280-
attr: k,
281-
},
282-
))
277+
if desc.open_attributes() {
278+
vparser.val_into_restricted_expr(v.into(), None, || {
279+
JsonDeserializationErrorContext::EntityAttribute {
280+
uid: uid.clone(),
281+
attr: k.clone(),
282+
}
283+
})?
284+
} else {
285+
return Err(JsonDeserializationError::EntitySchemaConformance(
286+
EntitySchemaConformanceError::UnexpectedEntityAttr {
287+
uid: uid.clone(),
288+
attr: k,
289+
},
290+
));
291+
}
283292
}
284293
Some(expected_ty) => vparser.val_into_restricted_expr(
285294
v.into(),

cedar-policy-core/src/entities/json/schema.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ pub trait EntityTypeDescription {
110110

111111
/// Get the entity types which are allowed to be parents of this entity type.
112112
fn allowed_parent_types(&self) -> Arc<HashSet<EntityType>>;
113+
114+
/// May entities with this type have attributes other than those specified
115+
/// in the schema
116+
fn open_attributes(&self) -> bool;
113117
}
114118

115119
/// Simple type that implements `EntityTypeDescription` by expecting no
@@ -132,4 +136,7 @@ impl EntityTypeDescription for NullEntityTypeDescription {
132136
fn allowed_parent_types(&self) -> Arc<HashSet<EntityType>> {
133137
Arc::new(HashSet::new())
134138
}
139+
fn open_attributes(&self) -> bool {
140+
false
141+
}
135142
}

cedar-policy-core/src/entities/json/schema_types.rs

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ pub enum SchemaType {
4444
Record {
4545
/// Attributes and their types
4646
attrs: HashMap<SmolStr, AttributeType>,
47+
/// Can a record with this type have attributes other than those specified in `attrs`
48+
open_attrs: bool,
4749
},
4850
/// Entity
4951
Entity {
@@ -119,7 +121,16 @@ impl SchemaType {
119121
(Set { element_ty: elty1 }, Set { element_ty: elty2 }) => {
120122
elty1.is_consistent_with(elty2)
121123
}
122-
(Record { attrs: attrs1 }, Record { attrs: attrs2 }) => {
124+
(
125+
Record {
126+
attrs: attrs1,
127+
open_attrs: open1,
128+
},
129+
Record {
130+
attrs: attrs2,
131+
open_attrs: open2,
132+
},
133+
) => {
123134
attrs1.iter().all(|(k, v)| {
124135
match attrs2.get(k) {
125136
Some(ty) => {
@@ -129,9 +140,9 @@ impl SchemaType {
129140
}
130141
None => {
131142
// attrs1 has the attribute, attrs2 does not.
132-
// if required in attrs1, incompatible.
133-
// otherwise fine
134-
!v.required
143+
// if required in attrs1 and attrs2 is
144+
// closed, incompatible. otherwise fine
145+
!v.required || *open2
135146
}
136147
}
137148
}) && attrs2.iter().all(|(k, v)| {
@@ -143,9 +154,9 @@ impl SchemaType {
143154
}
144155
None => {
145156
// attrs2 has the attribute, attrs1 does not.
146-
// if required in attrs2, incompatible.
147-
// otherwise fine
148-
!v.required
157+
// if required in attrs2 and attrs1 is closed,
158+
// incompatible. otherwise fine
159+
!v.required || *open1
149160
}
150161
}
151162
})
@@ -192,11 +203,17 @@ impl std::fmt::Display for SchemaType {
192203
Self::String => write!(f, "string"),
193204
Self::Set { element_ty } => write!(f, "(set of {})", &element_ty),
194205
Self::EmptySet => write!(f, "empty-set"),
195-
Self::Record { attrs } => {
196-
if attrs.is_empty() {
206+
Self::Record { attrs, open_attrs } => {
207+
if attrs.is_empty() && *open_attrs {
208+
write!(f, "any record")
209+
} else if attrs.is_empty() {
197210
write!(f, "empty record")
198211
} else {
199-
write!(f, "record with attributes: {{")?;
212+
if *open_attrs {
213+
write!(f, "record with at least attributes: {{")?;
214+
} else {
215+
write!(f, "record with attributes: {{")?;
216+
}
200217
// sorting attributes ensures that there is a single, deterministic
201218
// Display output for each `SchemaType`, which is important for
202219
// tests that check equality of error messages
@@ -324,7 +341,8 @@ pub fn schematype_of_restricted_expr(
324341
// but marking it optional is more flexible -- allows the
325342
// attribute type to `is_consistent_with()` more types
326343
Ok((k.clone(), AttributeType::optional(attr_type)))
327-
}).collect::<Result<HashMap<_,_>, GetSchemaTypeError>>()?
344+
}).collect::<Result<HashMap<_,_>, GetSchemaTypeError>>()?,
345+
open_attrs: false,
328346
})
329347
}
330348
ExprKind::ExtensionFunctionApp { fn_name, .. } => {
@@ -370,6 +388,7 @@ pub fn schematype_of_value(value: &Value) -> Result<SchemaType, HeterogeneousSet
370388
.iter()
371389
.map(|(k, v)| Ok((k.clone(), AttributeType::required(schematype_of_value(v)?))))
372390
.collect::<Result<_, HeterogeneousSetError>>()?,
391+
open_attrs: false,
373392
}),
374393
Value::ExtensionValue(ev) => Ok(SchemaType::Extension {
375394
name: ev.typename(),

cedar-policy-core/src/entities/json/value.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,7 @@ impl<'e> ValueParser<'e> {
514514
Some(
515515
expected_ty @ SchemaType::Record {
516516
attrs: expected_attrs,
517+
open_attrs,
517518
},
518519
) => match val {
519520
serde_json::Value::Object(mut actual_attrs) => {
@@ -537,14 +538,18 @@ impl<'e> ValueParser<'e> {
537538
}
538539
})
539540
.collect::<Result<Vec<(SmolStr, RestrictedExpr)>, JsonDeserializationError>>()?;
540-
// we've now checked that all expected attrs exist, and removed them from `actual_attrs`.
541-
// we still need to verify that we didn't have any unexpected attrs.
542-
if let Some((record_attr, _)) = actual_attrs.into_iter().next() {
543-
return Err(JsonDeserializationError::UnexpectedRecordAttr {
544-
ctx: Box::new(ctx2()),
545-
record_attr: record_attr.into(),
546-
});
541+
542+
if !open_attrs {
543+
// we've now checked that all expected attrs exist, and removed them from `actual_attrs`.
544+
// we still need to verify that we didn't have any unexpected attrs.
545+
if let Some((record_attr, _)) = actual_attrs.into_iter().next() {
546+
return Err(JsonDeserializationError::UnexpectedRecordAttr {
547+
ctx: Box::new(ctx2()),
548+
record_attr: record_attr.into(),
549+
});
550+
}
547551
}
552+
548553
// having duplicate keys should be impossible here (because
549554
// neither `actual_attrs` nor `expected_attrs` can have
550555
// duplicate keys; they're both maps), but we can still throw

0 commit comments

Comments
 (0)