diff --git a/cedar-policy-cli/Cargo.toml b/cedar-policy-cli/Cargo.toml index 1c7b07090d..c793d3b964 100644 --- a/cedar-policy-cli/Cargo.toml +++ b/cedar-policy-cli/Cargo.toml @@ -18,6 +18,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" miette = { version = "7.1.0", features = ["fancy"] } thiserror = "1.0" +semver = "1.0.23" [features] default = [] diff --git a/cedar-policy-cli/sample-data/tiny_sandboxes/format/formatted.cedar b/cedar-policy-cli/sample-data/tiny_sandboxes/format/formatted.cedar index 31164948b0..9cfd551c5f 100644 --- a/cedar-policy-cli/sample-data/tiny_sandboxes/format/formatted.cedar +++ b/cedar-policy-cli/sample-data/tiny_sandboxes/format/formatted.cedar @@ -2,4 +2,4 @@ permit ( principal == User::"alice", action == Action::"update", resource == Photo::"VacationPhoto94.jpg" -); \ No newline at end of file +); diff --git a/cedar-policy-cli/src/lib.rs b/cedar-policy-cli/src/lib.rs index 66c3270b83..7e4b7443c8 100644 --- a/cedar-policy-cli/src/lib.rs +++ b/cedar-policy-cli/src/lib.rs @@ -106,6 +106,8 @@ pub enum Commands { New(NewArgs), /// Partially evaluate an authorization request PartiallyAuthorize(PartiallyAuthorizeArgs), + /// Print Cedar language version + LanguageVersion, } #[derive(Args, Debug)] @@ -857,7 +859,7 @@ fn format_policies_inner(args: &FormatArgs) -> Result { "failed to write formatted policies to {policies_file}" ))?; } - _ => println!("{}", formatted_policy), + _ => print!("{}", formatted_policy), } Ok(are_policies_equivalent) } @@ -1035,6 +1037,15 @@ pub fn new(args: &NewArgs) -> CedarExitCode { } } +pub fn language_version() -> CedarExitCode { + let version = get_lang_version(); + println!( + "Cedar language version: {}.{}", + version.major, version.minor + ); + CedarExitCode::Success +} + fn create_slot_env(data: &HashMap) -> Result> { data.iter() .map(|(key, value)| Ok(EntityUid::from_str(value).map(|euid| (key.clone(), euid))?)) diff --git a/cedar-policy-cli/src/main.rs b/cedar-policy-cli/src/main.rs index 3772e634d1..069b9fac1e 100644 --- a/cedar-policy-cli/src/main.rs +++ b/cedar-policy-cli/src/main.rs @@ -20,9 +20,9 @@ use clap::Parser; use miette::ErrorHook; use cedar_policy_cli::{ - authorize, check_parse, evaluate, format_policies, link, new, partial_authorize, - translate_policy, translate_schema, validate, visualize, CedarExitCode, Cli, Commands, - ErrorFormat, + authorize, check_parse, evaluate, format_policies, language_version, link, new, + partial_authorize, translate_policy, translate_schema, validate, visualize, CedarExitCode, Cli, + Commands, ErrorFormat, }; fn main() -> CedarExitCode { @@ -53,5 +53,6 @@ fn main() -> CedarExitCode { Commands::TranslateSchema(args) => translate_schema(&args), Commands::New(args) => new(&args), Commands::PartiallyAuthorize(args) => partial_authorize(&args), + Commands::LanguageVersion => language_version(), } } diff --git a/cedar-policy-core/Cargo.toml b/cedar-policy-core/Cargo.toml index a4a40b55cf..da7ea93605 100644 --- a/cedar-policy-core/Cargo.toml +++ b/cedar-policy-core/Cargo.toml @@ -23,7 +23,7 @@ itertools = "0.13" ref-cast = "1.0" rustc_lexer = "0.1" thiserror = "1.0" -smol_str = { version = "0.2", features = ["serde"] } +smol_str = { version = "0.3", features = ["serde"] } stacker = "0.1.15" arbitrary = { version = "1", features = ["derive"], optional = true } miette = { version = "7.1.0", features = ["serde"] } @@ -51,6 +51,7 @@ test-util = [] # Experimental features. partial-eval = [] +entity-tags = [] wasm = ["serde-wasm-bindgen", "tsify", "wasm-bindgen"] [build-dependencies] diff --git a/cedar-policy-core/src/ast/entity.rs b/cedar-policy-core/src/ast/entity.rs index c33f5d8dce..ef88550f6e 100644 --- a/cedar-policy-core/src/ast/entity.rs +++ b/cedar-policy-core/src/ast/entity.rs @@ -306,7 +306,7 @@ pub struct Entity { uid: EntityUID, /// Internal BTreMap of attributes. - /// We use a btreemap so that the keys have a determenistic order. + /// We use a btreemap so that the keys have a deterministic order. /// /// In the serialized form of `Entity`, attribute values appear as /// `RestrictedExpr`s, for mostly historical reasons. @@ -315,6 +315,16 @@ pub struct Entity { /// Set of ancestors of this `Entity` (i.e., all direct and transitive /// parents), as UIDs ancestors: HashSet, + + /// Tags on this entity (RFC 82) + /// + /// Like for `attrs`, we use a `BTreeMap` so that the tags have a + /// deterministic order. + /// And like in `attrs`, the values in `tags` appear as `RestrictedExpr` in + /// the serialized form of `Entity`. + #[cfg(feature = "entity-tags")] + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + tags: BTreeMap, } impl std::hash::Hash for Entity { @@ -324,54 +334,75 @@ impl std::hash::Hash for Entity { } impl Entity { - /// Create a new `Entity` with this UID, attributes, and ancestors + /// Create a new `Entity` with this UID, attributes, ancestors, and tags /// /// # Errors - /// - Will error if any of the [`RestrictedExpr]`s in `attrs` error when evaluated + /// - Will error if any of the [`RestrictedExpr]`s in `attrs` or `tags` error when evaluated pub fn new( uid: EntityUID, - attrs: HashMap, + attrs: impl IntoIterator, ancestors: HashSet, + #[cfg(feature = "entity-tags")] tags: impl IntoIterator, extensions: &Extensions<'_>, ) -> Result { let evaluator = RestrictedEvaluator::new(extensions); + let evaluate_kvs = |(k, v): (SmolStr, RestrictedExpr), was_attr: bool| { + let attr_val = evaluator + .partial_interpret(v.as_borrowed()) + .map_err(|err| EntityAttrEvaluationError { + uid: uid.clone(), + attr_or_tag: k.clone(), + was_attr, + err, + })?; + Ok((k, attr_val.into())) + }; let evaluated_attrs = attrs .into_iter() - .map(|(k, v)| { - let attr_val = evaluator - .partial_interpret(v.as_borrowed()) - .map_err(|err| EntityAttrEvaluationError { - uid: uid.clone(), - attr: k.clone(), - err, - })?; - Ok((k, attr_val.into())) - }) + .map(|kv| evaluate_kvs(kv, true)) + .collect::>()?; + #[cfg(feature = "entity-tags")] + let evaluated_tags = tags + .into_iter() + .map(|kv| evaluate_kvs(kv, false)) .collect::>()?; Ok(Entity { uid, attrs: evaluated_attrs, ancestors, + #[cfg(feature = "entity-tags")] + tags: evaluated_tags, }) } - /// Create a new `Entity` with this UID, attributes, and ancestors. + /// Create a new `Entity` with this UID, ancestors, and an empty set of attributes + /// + /// Since there are no attributes, this method does not error, and returns `Self` instead of `Result` + pub fn new_empty_attrs(uid: EntityUID, ancestors: HashSet) -> Self { + Self::new_with_attr_partial_value(uid, HashMap::new(), ancestors) + } + + /// Create a new `Entity` with this UID, attributes, and ancestors (and no tags) /// /// Unlike in `Entity::new()`, in this constructor, attributes are expressed /// as `PartialValue`. + /// + /// Callers should consider directly using [`Entity::new_with_attr_partial_value_serialized_as_expr`] + /// if they would call this method by first building a map, as it will + /// deconstruct and re-build the map perhaps unnecessarily. pub fn new_with_attr_partial_value( uid: EntityUID, - attrs: HashMap, + attrs: impl IntoIterator, ancestors: HashSet, ) -> Self { - Entity { + Self::new_with_attr_partial_value_serialized_as_expr( uid, - attrs: attrs.into_iter().map(|(k, v)| (k, v.into())).collect(), // TODO(#540): can we do this without disassembling and reassembling the HashMap + attrs.into_iter().map(|(k, v)| (k, v.into())).collect(), ancestors, - } + ) } - /// Create a new `Entity` with this UID, attributes, and ancestors. + /// Create a new `Entity` with this UID, attributes, and ancestors (and no tags) /// /// Unlike in `Entity::new()`, in this constructor, attributes are expressed /// as `PartialValueSerializedAsExpr`. @@ -384,6 +415,8 @@ impl Entity { uid, attrs, ancestors, + #[cfg(feature = "entity-tags")] + tags: BTreeMap::new(), } } @@ -397,6 +430,12 @@ impl Entity { self.attrs.get(attr).map(|v| v.as_ref()) } + /// Get the value for the given tag, or `None` if not present + #[cfg(feature = "entity-tags")] + pub fn get_tag(&self, tag: &str) -> Option<&PartialValue> { + self.tags.get(tag).map(|v| v.as_ref()) + } + /// Is this `Entity` a descendant of `e` in the entity hierarchy? pub fn is_descendant_of(&self, e: &EntityUID) -> bool { self.ancestors.contains(e) @@ -412,22 +451,42 @@ impl Entity { self.attrs.len() } + /// Get the number of tags on this entity + #[cfg(feature = "entity-tags")] + pub fn tags_len(&self) -> usize { + self.tags.len() + } + /// Iterate over this entity's attribute names pub fn keys(&self) -> impl Iterator { self.attrs.keys() } + /// Iterate over this entity's tag names + #[cfg(feature = "entity-tags")] + pub fn tag_keys(&self) -> impl Iterator { + self.tags.keys() + } + /// Iterate over this entity's attributes pub fn attrs(&self) -> impl Iterator { self.attrs.iter().map(|(k, v)| (k, v.as_ref())) } - /// Create an `Entity` with the given UID, no attributes, and no parents. + /// Iterate over this entity's tags + #[cfg(feature = "entity-tags")] + pub fn tags(&self) -> impl Iterator { + self.tags.iter().map(|(k, v)| (k, v.as_ref())) + } + + /// Create an `Entity` with the given UID, no attributes, no parents, and no tags. pub fn with_uid(uid: EntityUID) -> Self { Self { uid, attrs: BTreeMap::new(), ancestors: HashSet::new(), + #[cfg(feature = "entity-tags")] + tags: BTreeMap::new(), } } @@ -438,6 +497,13 @@ impl Entity { self.uid == other.uid && self.attrs == other.attrs && self.ancestors == other.ancestors } + /// Set the UID to the given value. + // Only used for convenience in some tests + #[cfg(test)] + pub fn set_uid(&mut self, uid: EntityUID) { + self.uid = uid; + } + /// Set the given attribute to the given value. // Only used for convenience in some tests and when fuzzing #[cfg(any(test, fuzzing))] @@ -452,6 +518,21 @@ impl Entity { Ok(()) } + /// Set the given tag to the given value. + // Only used for convenience in some tests and when fuzzing + #[cfg(any(test, fuzzing))] + #[cfg(feature = "entity-tags")] + pub fn set_tag( + &mut self, + tag: SmolStr, + val: RestrictedExpr, + extensions: &Extensions<'_>, + ) -> Result<(), EvaluationError> { + let val = RestrictedEvaluator::new(extensions).partial_interpret(val.as_borrowed())?; + self.tags.insert(tag, val.into()); + Ok(()) + } + /// Mark the given `UID` as an ancestor of this `Entity`. // When fuzzing, `add_ancestor()` is fully `pub`. #[cfg(not(fuzzing))] @@ -464,23 +545,30 @@ impl Entity { self.ancestors.insert(uid); } - /// Consume the entity and return the entity's owned Uid, attributes and parents. + /// Consume the entity and return the entity's owned Uid, attributes, parents, and tags. pub fn into_inner( self, ) -> ( EntityUID, HashMap, HashSet, + HashMap, ) { let Self { uid, attrs, ancestors, + #[cfg(feature = "entity-tags")] + tags, } = self; ( uid, attrs.into_iter().map(|(k, v)| (k, v.0)).collect(), ancestors, + #[cfg(feature = "entity-tags")] + tags.into_iter().map(|(k, v)| (k, v.0)).collect(), + #[cfg(not(feature = "entity-tags"))] + HashMap::new(), ) } @@ -594,16 +682,20 @@ impl std::fmt::Display for PartialValueSerializedAsExpr { } } -/// Error type for evaluation errors when evaluating an entity attribute. +/// Error type for evaluation errors when evaluating an entity attribute or tag. /// Contains some extra contextual information and the underlying /// `EvaluationError`. +// +// This is NOT a publicly exported error type. #[derive(Debug, Diagnostic, Error)] -#[error("failed to evaluate attribute `{attr}` of `{uid}`: {err}")] +#[error("failed to evaluate {} `{attr_or_tag}` of `{uid}`: {err}", if *.was_attr { "attribute" } else { "tag" })] pub struct EntityAttrEvaluationError { /// UID of the entity where the error was encountered pub uid: EntityUID, - /// Attribute of the entity where the error was encountered - pub attr: SmolStr, + /// Attribute or tag of the entity where the error was encountered + pub attr_or_tag: SmolStr, + /// If `attr_or_tag` was an attribute (`true`) or tag (`false`) + pub was_attr: bool, /// Underlying evaluation error #[diagnostic(transparent)] pub err: EvaluationError, diff --git a/cedar-policy-core/src/ast/expr.rs b/cedar-policy-core/src/ast/expr.rs index 3b5de96495..54cb3c33d5 100644 --- a/cedar-policy-core/src/ast/expr.rs +++ b/cedar-policy-core/src/ast/expr.rs @@ -23,6 +23,7 @@ use miette::Diagnostic; use serde::{Deserialize, Serialize}; use smol_str::SmolStr; use std::{ + borrow::Cow, collections::{btree_map, BTreeMap, HashMap}, hash::{Hash, Hasher}, mem, @@ -345,6 +346,11 @@ impl Expr { | BinaryOp::LessEq, .. } => Some(Type::Bool), + #[cfg(feature = "entity-tags")] + ExprKind::BinaryApp { + op: BinaryOp::HasTag, + .. + } => Some(Type::Bool), ExprKind::ExtensionFunctionApp { fn_name, .. } => extensions .func(fn_name) .ok()? @@ -355,6 +361,12 @@ impl Expr { // a record `Type::Record` tells us nothing about the type of the // attribute. ExprKind::GetAttr { .. } => None, + // similarly to `GetAttr` + #[cfg(feature = "entity-tags")] + ExprKind::BinaryApp { + op: BinaryOp::GetTag, + .. + } => None, ExprKind::HasAttr { .. } => Some(Type::Bool), ExprKind::Like { .. } => Some(Type::Bool), ExprKind::Is { .. } => Some(Type::Bool), @@ -475,22 +487,36 @@ impl Expr { ExprBuilder::new().is_in(e1, e2) } - /// Create a 'contains' expression. + /// Create a `contains` expression. /// First argument must have Set type. pub fn contains(e1: Expr, e2: Expr) -> Self { ExprBuilder::new().contains(e1, e2) } - /// Create a 'contains_all' expression. Arguments must evaluate to Set type + /// Create a `containsAll` expression. Arguments must evaluate to Set type pub fn contains_all(e1: Expr, e2: Expr) -> Self { ExprBuilder::new().contains_all(e1, e2) } - /// Create an 'contains_any' expression. Arguments must evaluate to Set type + /// Create a `containsAny` expression. Arguments must evaluate to Set type pub fn contains_any(e1: Expr, e2: Expr) -> Self { ExprBuilder::new().contains_any(e1, e2) } + /// Create a `getTag` expression. + /// `expr` must evaluate to Entity type, `tag` must evaluate to String type. + #[cfg(feature = "entity-tags")] + pub fn get_tag(expr: Expr, tag: Expr) -> Self { + ExprBuilder::new().get_tag(expr, tag) + } + + /// Create a `hasTag` expression. + /// `expr` must evaluate to Entity type, `tag` must evaluate to String type. + #[cfg(feature = "entity-tags")] + pub fn has_tag(expr: Expr, tag: Expr) -> Self { + ExprBuilder::new().has_tag(expr, tag) + } + /// Create an `Expr` which evaluates to a Set of the given `Expr`s pub fn set(exprs: impl IntoIterator) -> Self { ExprBuilder::new().set(exprs) @@ -532,8 +558,7 @@ impl Expr { ExprBuilder::new().binary_app(op, arg1, arg2) } - /// Create an `Expr` which gets the attribute of some `Entity` or the field - /// of some record. + /// Create an `Expr` which gets a given attribute of a given `Entity` or record. /// /// `expr` must evaluate to either Entity or Record type pub fn get_attr(expr: Expr, attr: SmolStr) -> Self { @@ -541,7 +566,7 @@ impl Expr { } /// Create an `Expr` which tests for the existence of a given - /// attribute on a given `Entity`, or field on a given record. + /// attribute on a given `Entity` or record. /// /// `expr` must evaluate to either Entity or Record type pub fn has_attr(expr: Expr, attr: SmolStr) -> Self { @@ -727,7 +752,7 @@ impl SubstitutionFunction for UntypedSubstitution { } } -impl std::fmt::Display for Expr { +impl std::fmt::Display for Expr { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // To avoid code duplication between pretty-printers for AST Expr and EST Expr, // we just convert to EST and use the EST pretty-printer. @@ -1061,6 +1086,28 @@ impl ExprBuilder { }) } + /// Create a 'getTag' expression. + /// `expr` must evaluate to Entity type, `tag` must evaluate to String type. + #[cfg(feature = "entity-tags")] + pub fn get_tag(self, expr: Expr, tag: Expr) -> Expr { + self.with_expr_kind(ExprKind::BinaryApp { + op: BinaryOp::GetTag, + arg1: Arc::new(expr), + arg2: Arc::new(tag), + }) + } + + /// Create a 'hasTag' expression. + /// `expr` must evaluate to Entity type, `tag` must evaluate to String type. + #[cfg(feature = "entity-tags")] + pub fn has_tag(self, expr: Expr, tag: Expr) -> Expr { + self.with_expr_kind(ExprKind::BinaryApp { + op: BinaryOp::HasTag, + arg1: Arc::new(expr), + arg2: Arc::new(tag), + }) + } + /// Create an `Expr` which evaluates to a Set of the given `Expr`s pub fn set(self, exprs: impl IntoIterator>) -> Expr { self.with_expr_kind(ExprKind::Set(Arc::new(exprs.into_iter().collect()))) @@ -1131,8 +1178,7 @@ impl ExprBuilder { }) } - /// Create an `Expr` which gets the attribute of some `Entity` or the field - /// of some record. + /// Create an `Expr` which gets a given attribute of a given `Entity` or record. /// /// `expr` must evaluate to either Entity or Record type pub fn get_attr(self, expr: Expr, attr: SmolStr) -> Expr { @@ -1143,7 +1189,7 @@ impl ExprBuilder { } /// Create an `Expr` which tests for the existence of a given - /// attribute on a given `Entity`, or field on a given record. + /// attribute on a given `Entity` or record. /// /// `expr` must evaluate to either Entity or Record type pub fn has_attr(self, expr: Expr, attr: SmolStr) -> Expr { @@ -1272,24 +1318,31 @@ pub mod expression_construction_errors { /// implementations that ignore any source information or other generic data /// used to annotate the `Expr`. #[derive(Eq, Debug, Clone)] -pub struct ExprShapeOnly<'a, T = ()>(&'a Expr); +pub struct ExprShapeOnly<'a, T: Clone = ()>(Cow<'a, Expr>); -impl<'a, T> ExprShapeOnly<'a, T> { - /// Construct an `ExprShapeOnly` from an `Expr`. The `Expr` is not modified, - /// but any comparisons on the resulting `ExprShapeOnly` will ignore source - /// information and generic data. - pub fn new(e: &'a Expr) -> ExprShapeOnly<'a, T> { - ExprShapeOnly(e) +impl<'a, T: Clone> ExprShapeOnly<'a, T> { + /// Construct an `ExprShapeOnly` from a borrowed `Expr`. The `Expr` is not + /// modified, but any comparisons on the resulting `ExprShapeOnly` will + /// ignore source information and generic data. + pub fn new_from_borrowed(e: &'a Expr) -> ExprShapeOnly<'a, T> { + ExprShapeOnly(Cow::Borrowed(e)) + } + + /// Construct an `ExprShapeOnly` from an owned `Expr`. The `Expr` is not + /// modified, but any comparisons on the resulting `ExprShapeOnly` will + /// ignore source information and generic data. + pub fn new_from_owned(e: Expr) -> ExprShapeOnly<'a, T> { + ExprShapeOnly(Cow::Owned(e)) } } -impl<'a, T> PartialEq for ExprShapeOnly<'a, T> { +impl<'a, T: Clone> PartialEq for ExprShapeOnly<'a, T> { fn eq(&self, other: &Self) -> bool { - self.0.eq_shape(other.0) + self.0.eq_shape(&other.0) } } -impl<'a, T> Hash for ExprShapeOnly<'a, T> { +impl<'a, T: Clone> Hash for ExprShapeOnly<'a, T> { fn hash(&self, state: &mut H) { self.0.hash_shape(state); } @@ -1908,7 +1961,10 @@ mod test { fn expr_shape_only_not_eq() { let expr1 = ExprBuilder::with_data(1).val(1); let expr2 = ExprBuilder::with_data(1).val(2); - assert_ne!(ExprShapeOnly::new(&expr1), ExprShapeOnly::new(&expr2)); + assert_ne!( + ExprShapeOnly::new_from_borrowed(&expr1), + ExprShapeOnly::new_from_borrowed(&expr2) + ); } #[test] diff --git a/cedar-policy-core/src/ast/expr_iterator.rs b/cedar-policy-core/src/ast/expr_iterator.rs index 198501f44d..749ead5d29 100644 --- a/cedar-policy-core/src/ast/expr_iterator.rs +++ b/cedar-policy-core/src/ast/expr_iterator.rs @@ -56,11 +56,7 @@ impl<'a, T> Iterator for ExprIterator<'a, T> { self.expression_stack.push(then_expr); self.expression_stack.push(else_expr); } - ExprKind::And { left, right } => { - self.expression_stack.push(left); - self.expression_stack.push(right); - } - ExprKind::Or { left, right } => { + ExprKind::And { left, right } | ExprKind::Or { left, right } => { self.expression_stack.push(left); self.expression_stack.push(right); } @@ -71,29 +67,21 @@ impl<'a, T> Iterator for ExprIterator<'a, T> { self.expression_stack.push(arg1); self.expression_stack.push(arg2); } - ExprKind::ExtensionFunctionApp { args, .. } => { - for arg in args.as_ref() { - self.expression_stack.push(arg); - } - } - ExprKind::GetAttr { expr, attr: _ } => { - self.expression_stack.push(expr); - } - ExprKind::HasAttr { expr, attr: _ } => { - self.expression_stack.push(expr); - } - ExprKind::Like { expr, pattern: _ } => { + ExprKind::GetAttr { expr, attr: _ } + | ExprKind::HasAttr { expr, attr: _ } + | ExprKind::Like { expr, pattern: _ } + | ExprKind::Is { + expr, + entity_type: _, + } => { self.expression_stack.push(expr); } - ExprKind::Set(elems) => { - self.expression_stack.extend(elems.as_ref()); + ExprKind::ExtensionFunctionApp { args: exprs, .. } | ExprKind::Set(exprs) => { + self.expression_stack.extend(exprs.as_ref()); } ExprKind::Record(map) => { self.expression_stack.extend(map.values()); } - ExprKind::Is { expr, .. } => { - self.expression_stack.push(expr); - } } Some(next_expr) } diff --git a/cedar-policy-core/src/ast/name.rs b/cedar-policy-core/src/ast/name.rs index 4cc7a6d147..398f670dcd 100644 --- a/cedar-policy-core/src/ast/name.rs +++ b/cedar-policy-core/src/ast/name.rs @@ -592,8 +592,14 @@ impl AsRef for Name { #[cfg(feature = "arbitrary")] impl<'a> arbitrary::Arbitrary<'a> for Name { fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + // Computing hash of long id strings can be expensive + // Hence we limit the size of `path` such that DRT does not report slow + // units + let path_size = u.int_in_range(0..=8)?; let basename: UnreservedId = u.arbitrary()?; - let path: Vec = u.arbitrary()?; + let path: Vec = (0..path_size) + .map(|_| u.arbitrary()) + .collect::, _>>()?; let name = InternalName::new(basename.into(), path.into_iter().map(|id| id.into()), None); // PANIC SAFETY: `name` is made of `UnreservedId`s and thus should be a valid `Name` #[allow(clippy::unwrap_used)] diff --git a/cedar-policy-core/src/ast/ops.rs b/cedar-policy-core/src/ast/ops.rs index 8223c94011..e310e60a0a 100644 --- a/cedar-policy-core/src/ast/ops.rs +++ b/cedar-policy-core/src/ast/ops.rs @@ -89,6 +89,18 @@ pub enum BinaryOp { /// /// Arguments must have Set type ContainsAny, + + /// Get a tag of an entity. + /// + /// First argument must have Entity type, second argument must have String type. + #[cfg(feature = "entity-tags")] + GetTag, + + /// Does the given `expr` have the given `tag`? + /// + /// First argument must have Entity type, second argument must have String type. + #[cfg(feature = "entity-tags")] + HasTag, } impl std::fmt::Display for UnaryOp { @@ -113,6 +125,10 @@ impl std::fmt::Display for BinaryOp { BinaryOp::Contains => write!(f, "contains"), BinaryOp::ContainsAll => write!(f, "containsAll"), BinaryOp::ContainsAny => write!(f, "containsAny"), + #[cfg(feature = "entity-tags")] + BinaryOp::GetTag => write!(f, "getTag"), + #[cfg(feature = "entity-tags")] + BinaryOp::HasTag => write!(f, "hasTag"), } } } diff --git a/cedar-policy-core/src/entities.rs b/cedar-policy-core/src/entities.rs index 142394cdae..92a44ca0b0 100644 --- a/cedar-policy-core/src/entities.rs +++ b/cedar-policy-core/src/entities.rs @@ -466,7 +466,9 @@ mod json_parsing_tests { ); let parser: EntityJsonParser<'_, '_> = EntityJsonParser::new(None, Extensions::all_available(), TCComputation::ComputeNow); - parser.from_json_value(v).unwrap(); + parser + .from_json_value(v) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); } #[test] @@ -493,7 +495,9 @@ mod json_parsing_tests { } ]); - let addl_entities = parser.iter_from_json_value(new).unwrap(); + let addl_entities = parser + .iter_from_json_value(new) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let err = simple_entities(&parser).add_entities( addl_entities, None::<&NoEntitiesSchema>, @@ -531,7 +535,9 @@ mod json_parsing_tests { } ]); - let addl_entities = parser.iter_from_json_value(new).unwrap(); + let addl_entities = parser + .iter_from_json_value(new) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let err = simple_entities(&parser).add_entities( addl_entities, None::<&NoEntitiesSchema>, @@ -568,7 +574,9 @@ mod json_parsing_tests { } ]); - let addl_entities = parser.iter_from_json_value(new).unwrap(); + let addl_entities = parser + .iter_from_json_value(new) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let err = simple_entities(&parser).add_entities( addl_entities, None::<&NoEntitiesSchema>, @@ -609,7 +617,9 @@ mod json_parsing_tests { } ]); - let addl_entities = parser.iter_from_json_value(new).unwrap(); + let addl_entities = parser + .iter_from_json_value(new) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let es = simple_entities(&parser) .add_entities( addl_entities, @@ -646,7 +656,9 @@ mod json_parsing_tests { } ]); - let addl_entities = parser.iter_from_json_value(new).unwrap(); + let addl_entities = parser + .iter_from_json_value(new) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let es = simple_entities(&parser) .add_entities( addl_entities, @@ -685,7 +697,9 @@ mod json_parsing_tests { } ]); - let addl_entities = parser.iter_from_json_value(new).unwrap(); + let addl_entities = parser + .iter_from_json_value(new) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let es = simple_entities(&parser) .add_entities( addl_entities, @@ -723,7 +737,9 @@ mod json_parsing_tests { } ]); - let addl_entities = parser.iter_from_json_value(new).unwrap(); + let addl_entities = parser + .iter_from_json_value(new) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let es = simple_entities(&parser) .add_entities( addl_entities, @@ -748,7 +764,9 @@ mod json_parsing_tests { {"uid":{ "type" : "Test", "id" : "jeff" }, "attrs" : {}, "parents" : []}, {"uid":{ "type" : "Test", "id" : "jeff" }, "attrs" : {}, "parents" : []}]); - let addl_entities = parser.iter_from_json_value(new).unwrap(); + let addl_entities = parser + .iter_from_json_value(new) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let err = simple_entities(&parser) .add_entities( addl_entities, @@ -767,7 +785,9 @@ mod json_parsing_tests { let parser: EntityJsonParser<'_, '_> = EntityJsonParser::new(None, Extensions::all_available(), TCComputation::ComputeNow); let new = serde_json::json!([{"uid":{ "type": "Test", "id": "alice" }, "attrs" : {}, "parents" : []}]); - let addl_entities = parser.iter_from_json_value(new).unwrap(); + let addl_entities = parser + .iter_from_json_value(new) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let err = simple_entities(&parser).add_entities( addl_entities, None::<&NoEntitiesSchema>, @@ -820,7 +840,9 @@ mod json_parsing_tests { }, ] ); - parser.from_json_value(json).expect("JSON is correct") + parser + .from_json_value(json) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))) } /// Ensure the initial conditions of the entities still hold @@ -881,7 +903,7 @@ mod json_parsing_tests { EntityJsonParser::new(None, Extensions::all_available(), TCComputation::ComputeNow); let es = eparser .from_json_value(json) - .expect("JSON is correct") + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))) .partial(); let alice = es.entity(&EntityUID::with_eid("alice")).unwrap(); @@ -930,13 +952,24 @@ mod json_parsing_tests { }, "attrs": {}, "parents": [] + }, + { + "uid" : { + "type" : "test_entity_type", + "id" : "josephine" + }, + "attrs": {}, + "parents": [], + "tags": {} } ] ); let eparser: EntityJsonParser<'_, '_> = EntityJsonParser::new(None, Extensions::all_available(), TCComputation::ComputeNow); - let es = eparser.from_json_value(json).expect("JSON is correct"); + let es = eparser + .from_json_value(json) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let alice = es.entity(&EntityUID::with_eid("alice")).unwrap(); // Double check transitive closure computation @@ -1166,7 +1199,9 @@ mod json_parsing_tests { let eparser: EntityJsonParser<'_, '_> = EntityJsonParser::new(None, Extensions::all_available(), TCComputation::ComputeNow); - let es = eparser.from_json_value(json).expect("JSON is correct"); + let es = eparser + .from_json_value(json) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); let alice = es.entity(&EntityUID::with_eid("alice")).unwrap(); assert_eq!(alice.get("bacon"), Some(&PartialValue::from("eggs"))); @@ -1255,7 +1290,9 @@ mod json_parsing_tests { let eparser: EntityJsonParser<'_, '_> = EntityJsonParser::new(None, Extensions::all_available(), TCComputation::ComputeNow); - let es = eparser.from_json_value(json).expect("JSON is correct"); + let es = eparser + .from_json_value(json) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); // check that all five entities exist let alice = es.entity(&EntityUID::with_eid("alice")).unwrap(); @@ -1692,15 +1729,26 @@ mod json_parsing_tests { vec![RestrictedExpr::val("222.222.222.222")], ), ), - ] - .into_iter() - .collect(), + ], [ EntityUID::with_eid("parent1"), EntityUID::with_eid("parent2"), ] .into_iter() .collect(), + #[cfg(feature = "entity-tags")] + [ + // note that `foo` is also an attribute, with a different type + ("foo".into(), RestrictedExpr::val(2345)), + // note that `bar` is also an attribute, with the same type + ("bar".into(), RestrictedExpr::val(-1)), + // note that `pancakes` is not an attribute. Also note that, in + // this non-schema world, tags need not all have the same type. + ( + "pancakes".into(), + RestrictedExpr::val(EntityUID::with_eid("pancakes")), + ), + ], Extensions::all_available(), ) .unwrap(); @@ -1726,15 +1774,15 @@ mod json_parsing_tests { // record literal that happens to look like an escape "oops".into(), RestrictedExpr::record([("__entity".into(), RestrictedExpr::val("hi"))]).unwrap(), - )] - .into_iter() - .collect(), + )], [ EntityUID::with_eid("parent1"), EntityUID::with_eid("parent2"), ] .into_iter() .collect(), + #[cfg(feature = "entity-tags")] + [], Extensions::all_available(), ) .unwrap(); @@ -1801,7 +1849,9 @@ mod json_parsing_tests { ); let eparser: EntityJsonParser<'_, '_> = EntityJsonParser::new(None, Extensions::all_available(), TCComputation::ComputeNow); - assert_matches!(eparser.from_json_value(json), Ok(_)); + eparser + .from_json_value(json) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); } /// test that duplicate keys in a record is an error @@ -1935,6 +1985,7 @@ mod entities_tests { #[allow(clippy::panic)] #[cfg(test)] mod schema_based_parsing_tests { + use super::json::NullEntityTypeDescription; use super::*; use crate::extensions::Extensions; use crate::test_utils::*; @@ -1944,7 +1995,7 @@ mod schema_based_parsing_tests { use std::collections::HashSet; use std::sync::Arc; - /// Mock schema impl used for these tests + /// Mock schema impl used for most of these tests struct MockSchema; impl Schema for MockSchema { type EntityTypeDescription = MockEmployeeDescription; @@ -1959,9 +2010,7 @@ mod schema_based_parsing_tests { match action.to_string().as_str() { r#"Action::"view""# => Some(Arc::new(Entity::new_with_attr_partial_value( action.clone(), - [(SmolStr::from("foo"), PartialValue::from(34))] - .into_iter() - .collect(), + [(SmolStr::from("foo"), PartialValue::from(34))], [r#"Action::"readOnly""#.parse().expect("valid uid")] .into_iter() .collect(), @@ -1991,7 +2040,45 @@ mod schema_based_parsing_tests { } } - /// Mock schema impl for the `Employee` type used in these tests + /// Mock schema impl with an entity type that doesn't have a tags declaration + struct MockSchemaNoTags; + impl Schema for MockSchemaNoTags { + type EntityTypeDescription = NullEntityTypeDescription; + type ActionEntityIterator = std::iter::Empty>; + fn entity_type(&self, entity_type: &EntityType) -> Option { + match entity_type.to_string().as_str() { + "Employee" => Some(NullEntityTypeDescription::new("Employee".parse().unwrap())), + _ => None, + } + } + fn action(&self, action: &EntityUID) -> Option> { + match action.to_string().as_str() { + r#"Action::"view""# => Some(Arc::new(Entity::with_uid( + r#"Action::"view""#.parse().expect("valid uid"), + ))), + _ => None, + } + } + fn entity_types_with_basename<'a>( + &'a self, + basename: &'a UnreservedId, + ) -> Box + 'a> { + match basename.as_ref() { + "Employee" => Box::new(std::iter::once(EntityType::from(Name::unqualified_name( + basename.clone(), + )))), + "Action" => Box::new(std::iter::once(EntityType::from(Name::unqualified_name( + basename.clone(), + )))), + _ => Box::new(std::iter::empty()), + } + } + fn action_entities(&self) -> Self::ActionEntityIterator { + std::iter::empty() + } + } + + /// Mock schema impl for the `Employee` type used in most of these tests struct MockEmployeeDescription; impl EntityTypeDescription for MockEmployeeDescription { fn entity_type(&self) -> EntityType { @@ -2056,6 +2143,13 @@ mod schema_based_parsing_tests { } } + #[cfg(feature = "entity-tags")] + fn tag_type(&self) -> Option { + Some(SchemaType::Set { + element_ty: Box::new(SchemaType::String), + }) + } + fn required_attrs(&self) -> Box> { Box::new( [ @@ -2087,6 +2181,7 @@ mod schema_based_parsing_tests { /// JSON that should parse differently with and without the above schema #[test] fn with_and_without_schema() { + #[cfg(feature = "entity-tags")] let entitiesjson = json!( [ { @@ -2110,7 +2205,38 @@ mod schema_based_parsing_tests { "trust_score": "5.7", "tricky": { "type": "Employee", "id": "34FB87" } }, - "parents": [] + "parents": [], + "tags": { + "someTag": ["pancakes"], + }, + } + ] + ); + #[cfg(not(feature = "entity-tags"))] + let entitiesjson = json!( + [ + { + "uid": { "type": "Employee", "id": "12UA45" }, + "attrs": { + "isFullTime": true, + "numDirectReports": 3, + "department": "Sales", + "manager": { "type": "Employee", "id": "34FB87" }, + "hr_contacts": [ + { "type": "HR", "id": "aaaaa" }, + { "type": "HR", "id": "bbbbb" } + ], + "json_blob": { + "inner1": false, + "inner2": "-*/", + "inner3": { "innerinner": { "type": "Employee", "id": "09AE76" }}, + }, + "home_ip": "222.222.222.101", + "work_ip": { "fn": "ip", "arg": "2.2.2.0/24" }, + "trust_score": "5.7", + "tricky": { "type": "Employee", "id": "34FB87" } + }, + "parents": [], } ] ); @@ -2121,7 +2247,7 @@ mod schema_based_parsing_tests { EntityJsonParser::new(None, Extensions::all_available(), TCComputation::ComputeNow); let parsed = eparser .from_json_value(entitiesjson.clone()) - .expect("Should parse without error"); + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); assert_eq!(parsed.iter().count(), 1); let parsed = parsed .entity(&r#"Employee::"12UA45""#.parse().unwrap()) @@ -2196,7 +2322,7 @@ mod schema_based_parsing_tests { ); let parsed = eparser .from_json_value(entitiesjson) - .expect("Should parse without error"); + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); assert_eq!(parsed.iter().count(), 1); let parsed = parsed .entity(&r#"Employee::"12UA45""#.parse().unwrap()) @@ -2205,6 +2331,15 @@ mod schema_based_parsing_tests { .get("isFullTime") .expect("isFullTime attr should exist"); assert_eq!(is_full_time, &PartialValue::Value(Value::from(true)),); + #[cfg(feature = "entity-tags")] + let some_tag = parsed + .get_tag("someTag") + .expect("someTag attr should exist"); + #[cfg(feature = "entity-tags")] + assert_eq!( + some_tag, + &PartialValue::Value(Value::set(["pancakes".into()], None)) + ); let num_direct_reports = parsed .get("numDirectReports") .expect("numDirectReports attr should exist"); @@ -2600,6 +2735,7 @@ mod schema_based_parsing_tests { ); }); + // this version with explicit __entity and __extn escapes should also pass let entitiesjson = json!( [ { @@ -2629,7 +2765,64 @@ mod schema_based_parsing_tests { ); let _ = eparser .from_json_value(entitiesjson) - .expect("this version with explicit __entity and __extn escapes should also pass"); + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); + } + + /// tag has the wrong type + #[test] + fn type_mismatch_in_tag() { + let entitiesjson = json!( + [ + { + "uid": { "type": "Employee", "id": "12UA45" }, + "attrs": { + "isFullTime": true, + "numDirectReports": 3, + "department": "Sales", + "manager": { "type": "Employee", "id": "34FB87" }, + "hr_contacts": [ + { "type": "HR", "id": "aaaaa" }, + { "type": "HR", "id": "bbbbb" } + ], + "json_blob": { + "inner1": false, + "inner2": "-*/", + "inner3": { "innerinner": { "type": "Employee", "id": "09AE76" }}, + }, + "home_ip": "222.222.222.101", + "work_ip": { "fn": "ip", "arg": "2.2.2.0/24" }, + "trust_score": "5.7", + "tricky": { "type": "Employee", "id": "34FB87" } + }, + "parents": [], + "tags": { + "someTag": "pancakes", + } + } + ] + ); + let eparser = EntityJsonParser::new( + Some(&MockSchema), + Extensions::all_available(), + TCComputation::ComputeNow, + ); + #[cfg(feature = "entity-tags")] + let expected_error_msg = + ExpectedErrorMessageBuilder::error_starts_with("error during entity deserialization") + .source(r#"in tag `someTag` on `Employee::"12UA45"`, type mismatch: value was expected to have type [string], but it actually has type string: `"pancakes"`"#) + .build(); + #[cfg(not(feature = "entity-tags"))] + let expected_error_msg = + ExpectedErrorMessageBuilder::error_starts_with("error during entity deserialization") + .source(r#"found a tag `someTag` on `Employee::"12UA45"`, but no tags should exist on `Employee::"12UA45"` according to the schema"#) + .build(); + assert_matches!(eparser.from_json_value(entitiesjson.clone()), Err(e) => { + expect_err( + &entitiesjson, + &miette::Report::new(e), + &expected_error_msg, + ); + }); } #[cfg(all(feature = "decimal", feature = "ipaddr"))] @@ -2774,6 +2967,37 @@ mod schema_based_parsing_tests { }); } + /// unexpected entity tag + #[test] + fn unexpected_entity_tag() { + let entitiesjson = json!( + [ + { + "uid": { "type": "Employee", "id": "12UA45" }, + "attrs": {}, + "parents": [], + "tags": { + "someTag": 12, + } + } + ] + ); + let eparser = EntityJsonParser::new( + Some(&MockSchemaNoTags), + Extensions::all_available(), + TCComputation::ComputeNow, + ); + assert_matches!(eparser.from_json_value(entitiesjson.clone()), Err(e) => { + expect_err( + &entitiesjson, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("error during entity deserialization") + .source(r#"found a tag `someTag` on `Employee::"12UA45"`, but no tags should exist on `Employee::"12UA45"` according to the schema"#) + .build() + ); + }); + } + #[cfg(all(feature = "decimal", feature = "ipaddr"))] /// Test that involves parents of wrong types #[test] @@ -2902,7 +3126,7 @@ mod schema_based_parsing_tests { ); let entities = eparser .from_json_value(entitiesjson) - .expect("should parse sucessfully"); + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); assert_eq!(entities.iter().count(), 1); let expected_uid = r#"Action::"view""#.parse().expect("valid uid"); let parsed_entity = match entities.entity(&expected_uid) { @@ -3160,6 +3384,11 @@ mod schema_based_parsing_tests { } } + #[cfg(feature = "entity-tags")] + fn tag_type(&self) -> Option { + None + } + fn required_attrs(&self) -> Box> { Box::new( ["isFullTime", "department", "manager"] @@ -3197,7 +3426,7 @@ mod schema_based_parsing_tests { ); let parsed = eparser .from_json_value(entitiesjson) - .expect("Should parse without error"); + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); assert_eq!(parsed.iter().count(), 1); let parsed = parsed .entity(&r#"XYZCorp::Employee::"12UA45""#.parse().unwrap()) diff --git a/cedar-policy-core/src/entities/conformance/err.rs b/cedar-policy-core/src/entities/conformance/err.rs index aa54c076f2..666a611a3a 100644 --- a/cedar-policy-core/src/entities/conformance/err.rs +++ b/cedar-policy-core/src/entities/conformance/err.rs @@ -22,6 +22,10 @@ use smol_str::SmolStr; use thiserror::Error; /// Errors raised when entities do not conform to the schema +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. #[derive(Debug, Diagnostic, Error)] #[non_exhaustive] pub enum EntitySchemaConformanceError { @@ -29,6 +33,10 @@ pub enum EntitySchemaConformanceError { #[error(transparent)] #[diagnostic(transparent)] UnexpectedEntityAttr(UnexpectedEntityAttr), + /// Encountered tag, but no tags should exist on entities of this type + #[error(transparent)] + #[diagnostic(transparent)] + UnexpectedEntityTag(UnexpectedEntityTag), /// Didn't encounter attribute that should exist #[error(transparent)] #[diagnostic(transparent)] @@ -72,6 +80,13 @@ impl EntitySchemaConformanceError { }) } + pub(crate) fn unexpected_entity_tag(uid: EntityUID, tag: impl Into) -> Self { + Self::UnexpectedEntityTag(UnexpectedEntityTag { + uid, + tag: tag.into(), + }) + } + pub(crate) fn missing_entity_attr(uid: EntityUID, attr: impl Into) -> Self { Self::MissingRequiredEntityAttr(MissingRequiredEntityAttr { uid, @@ -122,6 +137,10 @@ impl EntitySchemaConformanceError { /// Error looking up an extension function. This error can occur when /// checking entity conformance because that may require getting information /// about any extension functions referenced in entity attribute values. +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. #[derive(Debug, Error, Diagnostic)] #[error("in attribute `{attr}` on `{uid}`, {err}")] pub struct ExtensionFunctionLookup { @@ -136,6 +155,10 @@ pub struct ExtensionFunctionLookup { /// Encountered an action whose definition doesn't precisely match the /// schema's declaration of that action +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. #[derive(Debug, Error, Diagnostic)] #[error("definition of action `{uid}` does not match its schema declaration")] #[diagnostic(help( @@ -147,6 +170,10 @@ pub struct ActionDeclarationMismatch { } /// Encountered an action which was not declared in the schema +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. #[derive(Debug, Error, Diagnostic)] #[error("found action entity `{uid}`, but it was not declared as an action in the schema")] pub struct UndeclaredAction { @@ -155,6 +182,10 @@ pub struct UndeclaredAction { } /// Found an ancestor of a type that's not allowed for that entity +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. #[derive(Debug, Error, Diagnostic)] #[error( "`{uid}` is not allowed to have an ancestor of type `{ancestor_ty}` according to the schema" @@ -167,6 +198,10 @@ pub struct InvalidAncestorType { } /// Encountered attribute that shouldn't exist on entities of this type +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. #[derive(Debug, Error, Diagnostic)] #[error("attribute `{attr}` on `{uid}` should not exist according to the schema")] pub struct UnexpectedEntityAttr { @@ -174,7 +209,25 @@ pub struct UnexpectedEntityAttr { attr: SmolStr, } +/// Encountered tag, but no tags should exist on entities of this type +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. +#[derive(Debug, Error, Diagnostic)] +#[error( + "found a tag `{tag}` on `{uid}`, but no tags should exist on `{uid}` according to the schema" +)] +pub struct UnexpectedEntityTag { + uid: EntityUID, + tag: SmolStr, +} + /// Didn't encounter attribute that should exist +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. #[derive(Debug, Error, Diagnostic)] #[error("expected entity `{uid}` to have attribute `{attr}`, but it does not")] pub struct MissingRequiredEntityAttr { @@ -182,10 +235,14 @@ pub struct MissingRequiredEntityAttr { attr: SmolStr, } -#[derive(Debug, Error, Diagnostic)] -#[error("in attribute `{attr}` on `{uid}`, {err}")] /// The given attribute on the given entity had a different type than the /// schema indicated +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. +#[derive(Debug, Error, Diagnostic)] +#[error("in attribute `{attr}` on `{uid}`, {err}")] pub struct TypeMismatch { uid: EntityUID, attr: SmolStr, @@ -195,6 +252,10 @@ pub struct TypeMismatch { /// Encountered an entity of a type which is not declared in the schema. /// Note that this error is only used for non-Action entity types. +// +// CAUTION: this type is publicly exported in `cedar-policy`. +// Don't make fields `pub`, don't make breaking changes, and use caution +// when adding public methods. #[derive(Debug, Error)] #[error("entity `{uid}` has type `{}` which is not declared in the schema", .uid.entity_type())] pub struct UnexpectedEntityTypeError { diff --git a/cedar-policy-core/src/entities/json/entities.rs b/cedar-policy-core/src/entities/json/entities.rs index 9b6e95ffd7..4863b1cab9 100644 --- a/cedar-policy-core/src/entities/json/entities.rs +++ b/cedar-policy-core/src/entities/json/entities.rs @@ -56,6 +56,12 @@ pub struct EntityJson { attrs: HashMap, /// Parents of the entity, specified in any form accepted by `EntityUidJson` parents: Vec, + #[serde_as(as = "serde_with::MapPreventDuplicates<_,_>")] + #[serde(default)] + #[serde(skip_serializing_if = "HashMap::is_empty")] + #[cfg_attr(feature = "wasm", tsify(type = "Record"))] + // the annotation covers duplicates in this `HashMap` itself, while the `JsonValueWithNoDuplicateKeys` covers duplicates in any records contained in tag values (including recursively) + tags: HashMap, } /// Struct used to parse entities from JSON. @@ -341,6 +347,52 @@ impl<'e, 's, S: Schema> EntityJsonParser<'e, 's, S> { } }) .collect::>()?; + let tags: HashMap = ejson + .tags + .into_iter() + .map(|(k, v)| match &entity_schema_info { + EntitySchemaInfo::NoSchema => Ok(( + k.clone(), + vparser.val_into_restricted_expr(v.into(), None, || { + JsonDeserializationErrorContext::EntityTag { + uid: uid.clone(), + tag: k.clone(), + } + })?, + )), + #[cfg(feature = "entity-tags")] + EntitySchemaInfo::NonAction(desc) => { + // Depending on the expected type, we may parse the contents + // of the tag differently. + let rexpr = match desc.tag_type() { + // `None` indicates no tags should exist -- see docs on + // the `tag_type()` trait method + None => { + return Err(JsonDeserializationError::EntitySchemaConformance( + EntitySchemaConformanceError::unexpected_entity_tag(uid.clone(), k), + )); + } + Some(expected_ty) => vparser.val_into_restricted_expr( + v.into(), + Some(&expected_ty), + || JsonDeserializationErrorContext::EntityTag { + uid: uid.clone(), + tag: k.clone(), + }, + )?, + }; + Ok((k, rexpr)) + } + #[cfg(not(feature = "entity-tags"))] + EntitySchemaInfo::NonAction(_) => { + // without the `entity-tags` feature, no schemas specify tags, + // so any tag that appears is necessarily a conformance error + Err(JsonDeserializationError::EntitySchemaConformance( + EntitySchemaConformanceError::unexpected_entity_tag(uid.clone(), k), + )) + } + }) + .collect::>()?; let is_parent_allowed = |parent_euid: &EntityUID| { // full validation isn't done in this function (see doc comments on // this function), but we do need to do the following check which @@ -373,7 +425,18 @@ impl<'e, 's, S: Schema> EntityJsonParser<'e, 's, S> { }) }) .collect::>()?; - Ok(Entity::new(uid, attrs, parents, self.extensions)?) + #[cfg(not(feature = "entity-tags"))] + if !tags.is_empty() { + return Err(JsonDeserializationError::UnsupportedEntityTags); + } + Ok(Entity::new( + uid, + attrs, + parents, + #[cfg(feature = "entity-tags")] + tags, + self.extensions, + )?) } } @@ -382,29 +445,39 @@ impl EntityJson { /// /// (for the reverse transformation, use `EntityJsonParser`) pub fn from_entity(entity: &Entity) -> Result { + let serialize_kpvalue = |(k, pvalue): (&SmolStr, &PartialValue)| -> Result<_, _> { + match pvalue { + PartialValue::Value(value) => { + let cedarvaluejson = CedarValueJson::from_value(value.clone())?; + Ok((k.clone(), serde_json::to_value(cedarvaluejson)?.into())) + } + PartialValue::Residual(expr) => match BorrowedRestrictedExpr::new(expr) { + Ok(expr) => { + let cedarvaluejson = CedarValueJson::from_expr(expr)?; + Ok((k.clone(), serde_json::to_value(cedarvaluejson)?.into())) + } + Err(_) => Err(JsonSerializationError::residual(expr.clone())), + }, + } + }; Ok(Self { // for now, we encode `uid` and `parents` using an implied `__entity` escape uid: EntityUidJson::ImplicitEntityEscape(TypeAndId::from(entity.uid())), attrs: entity .attrs() - .map(|(k, pvalue)| match pvalue { - PartialValue::Value(value) => { - let cedarvaluejson = CedarValueJson::from_value(value.clone())?; - Ok((k.clone(), serde_json::to_value(cedarvaluejson)?.into())) - } - PartialValue::Residual(expr) => match BorrowedRestrictedExpr::new(expr) { - Ok(expr) => { - let cedarvaluejson = CedarValueJson::from_expr(expr)?; - Ok((k.clone(), serde_json::to_value(cedarvaluejson)?.into())) - } - Err(_) => Err(JsonSerializationError::residual(expr.clone())), - }, - }) + .map(serialize_kpvalue) .collect::>()?, parents: entity .ancestors() .map(|euid| EntityUidJson::ImplicitEntityEscape(TypeAndId::from(euid.clone()))) .collect(), + #[cfg(feature = "entity-tags")] + tags: entity + .tags() + .map(serialize_kpvalue) + .collect::>()?, + #[cfg(not(feature = "entity-tags"))] + tags: HashMap::new(), }) } } diff --git a/cedar-policy-core/src/entities/json/err.rs b/cedar-policy-core/src/entities/json/err.rs index 645eb71fac..d6c43237bd 100644 --- a/cedar-policy-core/src/entities/json/err.rs +++ b/cedar-policy-core/src/entities/json/err.rs @@ -127,6 +127,10 @@ pub enum JsonDeserializationError { #[error(transparent)] #[diagnostic(transparent)] ReservedName(#[from] ReservedNameError), + /// Returned when entities have tags, but the `entity-tags` feature is not enabled + #[cfg(not(feature = "entity-tags"))] + #[error("entity tags are not supported in this build; to use entity tags, you must enable the `entity-tags` experimental feature")] + UnsupportedEntityTags, } impl JsonDeserializationError { @@ -460,6 +464,13 @@ pub enum JsonDeserializationErrorContext { /// Attribute where the error occurred attr: SmolStr, }, + /// The error occurred while deserializing the tag `tag` of an entity. + EntityTag { + /// Entity where the error occurred + uid: EntityUID, + /// Tag where the error occurred + tag: SmolStr, + }, /// The error occurred while deserializing the `parents` field of an entity. EntityParents { /// Entity where the error occurred @@ -558,6 +569,7 @@ impl std::fmt::Display for JsonDeserializationErrorContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::EntityAttribute { uid, attr } => write!(f, "in attribute `{attr}` on `{uid}`"), + Self::EntityTag { uid, tag } => write!(f, "in tag `{tag}` on `{uid}`"), Self::EntityParents { uid } => write!(f, "in parents field of `{uid}`"), Self::EntityUid => write!(f, "in uid field of "), Self::Context => write!(f, "while parsing context"), diff --git a/cedar-policy-core/src/entities/json/schema.rs b/cedar-policy-core/src/entities/json/schema.rs index 9f623fd5a9..6c701b38bd 100644 --- a/cedar-policy-core/src/entities/json/schema.rs +++ b/cedar-policy-core/src/entities/json/schema.rs @@ -18,7 +18,7 @@ use super::SchemaType; use crate::ast::{Entity, EntityType, EntityUID}; use crate::entities::{Name, UnreservedId}; use smol_str::SmolStr; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::sync::Arc; /// Trait for `Schema`s that can inform the parsing of Entity JSON data @@ -95,7 +95,7 @@ impl Schema for AllEntitiesNoAttrsSchema { fn action(&self, action: &EntityUID) -> Option> { Some(Arc::new(Entity::new_with_attr_partial_value( action.clone(), - HashMap::new(), + [], HashSet::new(), ))) } @@ -122,6 +122,12 @@ pub trait EntityTypeDescription { /// Returning `None` indicates that attribute should not exist. fn attr_type(&self, attr: &str) -> Option; + /// If this entity has tags, what type should the tags be? + /// + /// Returning `None` indicates that no tags should exist for this entity type. + #[cfg(feature = "entity-tags")] + fn tag_type(&self) -> Option; + /// Get the names of all the required attributes for this entity type. fn required_attrs<'s>(&'s self) -> Box + 's>; @@ -134,10 +140,10 @@ pub trait EntityTypeDescription { } /// Simple type that implements `EntityTypeDescription` by expecting no -/// attributes to exist +/// attributes, tags, or parents to exist #[derive(Debug, Clone)] pub struct NullEntityTypeDescription { - /// null description for this type + /// null description for this entity typename ty: EntityType, } impl EntityTypeDescription for NullEntityTypeDescription { @@ -147,6 +153,10 @@ impl EntityTypeDescription for NullEntityTypeDescription { fn attr_type(&self, _attr: &str) -> Option { None } + #[cfg(feature = "entity-tags")] + fn tag_type(&self) -> Option { + None + } fn required_attrs(&self) -> Box> { Box::new(std::iter::empty()) } @@ -157,3 +167,9 @@ impl EntityTypeDescription for NullEntityTypeDescription { false } } +impl NullEntityTypeDescription { + /// Create a new [`NullEntityTypeDescription`] for the given entity typename + pub fn new(ty: EntityType) -> Self { + Self { ty } + } +} diff --git a/cedar-policy-core/src/est.rs b/cedar-policy-core/src/est.rs index 27b984fc9f..af4ca58c58 100644 --- a/cedar-policy-core/src/est.rs +++ b/cedar-policy-core/src/est.rs @@ -299,8 +299,8 @@ impl From for Policy { } } -impl From for Clause { - fn from(expr: ast::Expr) -> Clause { +impl From> for Clause { + fn from(expr: ast::Expr) -> Clause { Clause::When(expr.into()) } } @@ -2213,6 +2213,141 @@ mod test { assert_eq!(circular_roundtrip(est.clone()), est); } + #[test] + fn entity_tags() { + let policy = r#" + permit(principal, action, resource) + when { + resource.hasTag("writeable") + && resource.getTag("writeable").contains(principal.group) + && principal.hasTag(context.foo) + && principal.getTag(context.foo) == 72 + }; + "#; + let cst = parser::text_to_cst::parse_policy(policy) + .unwrap() + .node + .unwrap(); + let est: Policy = cst.try_into().unwrap(); + let expected_json = json!( + { + "effect": "permit", + "principal": { + "op": "All", + }, + "action": { + "op": "All", + }, + "resource": { + "op": "All", + }, + "conditions": [ + { + "kind": "when", + "body": { + "&&": { + "left": { + "&&": { + "left": { + "&&": { + "left": { + "hasTag": { + "left": { + "Var": "resource" + }, + "right": { + "Value": "writeable" + } + } + }, + "right": { + "contains": { + "left": { + "getTag": { + "left": { + "Var": "resource" + }, + "right": { + "Value": "writeable" + } + } + }, + "right": { + ".": { + "left": { + "Var": "principal" + }, + "attr": "group" + } + } + } + } + } + }, + "right": { + "hasTag": { + "left": { + "Var": "principal" + }, + "right": { + ".": { + "left": { + "Var": "context", + }, + "attr": "foo" + } + } + } + }, + } + }, + "right": { + "==": { + "left": { + "getTag": { + "left": { + "Var": "principal" + }, + "right": { + ".": { + "left": { + "Var": "context", + }, + "attr": "foo" + } + } + } + }, + "right": { + "Value": 72 + } + } + } + } + } + } + ] + } + ); + assert_eq!( + serde_json::to_value(&est).unwrap(), + expected_json, + "\nExpected:\n{}\n\nActual:\n{}\n\n", + serde_json::to_string_pretty(&expected_json).unwrap(), + serde_json::to_string_pretty(&est).unwrap() + ); + let old_est = est.clone(); + let roundtripped = est_roundtrip(est); + assert_eq!(&old_est, &roundtripped); + let est = text_roundtrip(&old_est); + assert_eq!(&old_est, &est); + + #[cfg(feature = "entity-tags")] + assert_eq!(ast_roundtrip(est.clone()), est); + #[cfg(feature = "entity-tags")] + assert_eq!(circular_roundtrip(est.clone()), est); + } + #[test] fn like_special_patterns() { let policy = r#" diff --git a/cedar-policy-core/src/est/err.rs b/cedar-policy-core/src/est/err.rs index 8a8b60beb1..c5e159bb15 100644 --- a/cedar-policy-core/src/est/err.rs +++ b/cedar-policy-core/src/est/err.rs @@ -78,6 +78,10 @@ pub enum FromJsonError { #[error(transparent)] #[diagnostic(transparent)] InvalidActionType(#[from] parse_errors::InvalidActionType), + /// Returned when a policy uses entity tags, but the `entity-tags` feature is not enabled + #[cfg(not(feature = "entity-tags"))] + #[error("entity tags are not supported in this build; to use entity tags, you must enable the `entity-tags` experimental feature")] + UnsupportedEntityTags, } /// Errors arising while converting a policy set from its JSON representation (aka EST) into an AST diff --git a/cedar-policy-core/src/est/expr.rs b/cedar-policy-core/src/est/expr.rs index 5ef7b0d929..96fced0100 100644 --- a/cedar-policy-core/src/est/expr.rs +++ b/cedar-policy-core/src/est/expr.rs @@ -240,6 +240,22 @@ pub enum ExprNoExt { /// Right-hand argument (inside the `()`) right: Arc, }, + /// `getTag()` + #[serde(rename = "getTag")] + GetTag { + /// Left-hand argument (receiver) + left: Arc, + /// Right-hand argument (inside the `()`) + right: Arc, + }, + /// `hasTag()` + #[serde(rename = "hasTag")] + HasTag { + /// Left-hand argument (receiver) + left: Arc, + /// Right-hand argument (inside the `()`) + right: Arc, + }, /// Get-attribute #[serde(rename = ".")] GetAttr { @@ -478,6 +494,22 @@ impl Expr { }) } + /// `left.getTag(right)` + pub fn get_tag(left: Arc, right: Expr) -> Self { + Expr::ExprNoExt(ExprNoExt::GetTag { + left, + right: Arc::new(right), + }) + } + + /// `left.hasTag(right)` + pub fn has_tag(left: Arc, right: Expr) -> Self { + Expr::ExprNoExt(ExprNoExt::HasTag { + left, + right: Arc::new(right), + }) + } + /// `left.attr` pub fn get_attr(left: Expr, attr: SmolStr) -> Self { Expr::ExprNoExt(ExprNoExt::GetAttr { @@ -633,6 +665,20 @@ impl Expr { (*left).clone().try_into_ast(id.clone())?, (*right).clone().try_into_ast(id)?, )), + #[cfg(feature = "entity-tags")] + Expr::ExprNoExt(ExprNoExt::GetTag { left, right }) => Ok(ast::Expr::get_tag( + (*left).clone().try_into_ast(id.clone())?, + (*right).clone().try_into_ast(id)?, + )), + #[cfg(not(feature = "entity-tags"))] + Expr::ExprNoExt(ExprNoExt::GetTag { .. }) => Err(FromJsonError::UnsupportedEntityTags), + #[cfg(feature = "entity-tags")] + Expr::ExprNoExt(ExprNoExt::HasTag { left, right }) => Ok(ast::Expr::has_tag( + (*left).clone().try_into_ast(id.clone())?, + (*right).clone().try_into_ast(id)?, + )), + #[cfg(not(feature = "entity-tags"))] + Expr::ExprNoExt(ExprNoExt::HasTag { .. }) => Err(FromJsonError::UnsupportedEntityTags), Expr::ExprNoExt(ExprNoExt::GetAttr { left, attr }) => { Ok(ast::Expr::get_attr((*left).clone().try_into_ast(id)?, attr)) } @@ -723,8 +769,8 @@ impl Expr { } } -impl From for Expr { - fn from(expr: ast::Expr) -> Expr { +impl From> for Expr { + fn from(expr: ast::Expr) -> Expr { match expr.into_expr_kind() { ast::ExprKind::Lit(lit) => lit.into(), ast::ExprKind::Var(var) => var.into(), @@ -768,6 +814,10 @@ impl From for Expr { ast::BinaryOp::Contains => Expr::contains(Arc::new(arg1), arg2), ast::BinaryOp::ContainsAll => Expr::contains_all(Arc::new(arg1), arg2), ast::BinaryOp::ContainsAny => Expr::contains_any(Arc::new(arg1), arg2), + #[cfg(feature = "entity-tags")] + ast::BinaryOp::GetTag => Expr::get_tag(Arc::new(arg1), arg2), + #[cfg(feature = "entity-tags")] + ast::BinaryOp::HasTag => Expr::has_tag(Arc::new(arg1), arg2), } } ast::ExprKind::ExtensionFunctionApp { fn_name, args } => { @@ -1216,6 +1266,14 @@ impl TryFrom<&Node>> for Expr { left, extract_single_argument(args, "containsAny()", &access.loc)?, )), + "getTag" => Either::Right(Expr::get_tag( + left, + extract_single_argument(args, "getTag()", &access.loc)?, + )), + "hasTag" => Either::Right(Expr::has_tag( + left, + extract_single_argument(args, "hasTag()", &access.loc)?, + )), _ => { // have to add the "receiver" argument as // first in the list for the method call @@ -1513,6 +1571,14 @@ impl std::fmt::Display for ExprNoExt { maybe_with_parens(f, left)?; write!(f, ".containsAny({right})") } + ExprNoExt::GetTag { left, right } => { + maybe_with_parens(f, left)?; + write!(f, ".getTag({right})") + } + ExprNoExt::HasTag { left, right } => { + maybe_with_parens(f, left)?; + write!(f, ".hasTag({right})") + } ExprNoExt::GetAttr { left, attr } => { maybe_with_parens(f, left)?; write!(f, "[\"{}\"]", attr.escape_debug()) @@ -1631,6 +1697,8 @@ fn maybe_with_parens(f: &mut std::fmt::Formatter<'_>, expr: &Expr) -> std::fmt:: Expr::ExprNoExt(ExprNoExt::ContainsAny { .. }) | Expr::ExprNoExt(ExprNoExt::GetAttr { .. }) | Expr::ExprNoExt(ExprNoExt::HasAttr { .. }) | + Expr::ExprNoExt(ExprNoExt::GetTag { .. }) | + Expr::ExprNoExt(ExprNoExt::HasTag { .. }) | Expr::ExprNoExt(ExprNoExt::Like { .. }) | Expr::ExprNoExt(ExprNoExt::Is { .. }) | Expr::ExprNoExt(ExprNoExt::If { .. }) | diff --git a/cedar-policy-core/src/evaluator.rs b/cedar-policy-core/src/evaluator.rs index 2bf633077e..7b3d8e8f67 100644 --- a/cedar-policy-core/src/evaluator.rs +++ b/cedar-policy-core/src/evaluator.rs @@ -531,6 +531,55 @@ impl<'e> Evaluator<'e> { } } } + // GetTag and HasTag, which require an Entity on the left and a String on the right + #[cfg(feature = "entity-tags")] + BinaryOp::GetTag | BinaryOp::HasTag => { + let uid = arg1.get_as_entity()?; + let tag = arg2.get_as_string()?; + match op { + BinaryOp::GetTag => { + match self.entities.entity(uid) { + Dereference::NoSuchEntity => { + // intentionally using the location of the euid (the LHS) and not the entire GetTag expression + Err(EvaluationError::entity_does_not_exist( + Arc::new(uid.clone()), + arg1.source_loc().cloned(), + )) + } + Dereference::Residual(r) => Ok(PartialValue::Residual( + Expr::get_tag(r, Expr::val(tag.clone())), + )), + Dereference::Data(entity) => entity + .get_tag(tag) + .ok_or_else(|| { + EvaluationError::entity_tag_does_not_exist( + Arc::new(uid.clone()), + tag.clone(), + entity.tag_keys(), + entity.get(tag).is_some(), + entity.tags_len(), + loc.cloned(), // intentionally using the location of the entire `GetTag` expression + ) + }) + .cloned(), + } + } + BinaryOp::HasTag => match self.entities.entity(uid) { + Dereference::NoSuchEntity => Ok(false.into()), + Dereference::Residual(r) => Ok(PartialValue::Residual( + Expr::has_tag(r, Expr::val(tag.clone())), + )), + Dereference::Data(entity) => { + Ok(entity.get_tag(tag).is_some().into()) + } + }, + // PANIC SAFETY `op` is checked to be one of these two above + #[allow(clippy::unreachable)] + _ => { + unreachable!("Should have already checked that op was one of these") + } + } + } } } ExprKind::ExtensionFunctionApp { fn_name, args } => { @@ -772,6 +821,10 @@ impl<'e> Evaluator<'e> { uid, attr.clone(), entity.keys(), + #[cfg(feature = "entity-tags")] + entity.get_tag(attr).is_some(), + #[cfg(not(feature = "entity-tags"))] + false, entity.attrs_len(), source_loc.cloned(), ) @@ -911,6 +964,7 @@ pub mod test { use crate::{ entities::{EntityJsonParser, NoEntitiesSchema, TCComputation}, parser::{self, parse_expr, parse_policy_or_template, parse_policyset}, + test_utils::{expect_err, ExpectedErrorMessageBuilder}, }; use cool_asserts::assert_matches; @@ -962,10 +1016,18 @@ pub mod test { pub fn rich_entities() -> Entities { let entity_no_attrs_no_parents = Entity::with_uid(EntityUID::with_eid("entity_no_attrs_no_parents")); + let mut entity_with_attrs = Entity::with_uid(EntityUID::with_eid("entity_with_attrs")); entity_with_attrs .set_attr("spoon".into(), RestrictedExpr::val(787), Extensions::none()) .unwrap(); + entity_with_attrs + .set_attr( + "fork".into(), + RestrictedExpr::val("spoon"), + Extensions::none(), + ) + .unwrap(); entity_with_attrs .set_attr( "tags".into(), @@ -989,6 +1051,32 @@ pub mod test { Extensions::none(), ) .unwrap(); + + #[cfg(feature = "entity-tags")] + let mut entity_with_tags = Entity::with_uid(EntityUID::with_eid("entity_with_tags")); + #[cfg(feature = "entity-tags")] + entity_with_tags + .set_tag( + "spoon".into(), + RestrictedExpr::val(-121), + Extensions::none(), + ) + .unwrap(); + + #[cfg(not(feature = "entity-tags"))] + let entity_with_tags = Entity::with_uid(EntityUID::with_eid("entity_with_tags")); + + let mut entity_with_tags_and_attrs = entity_with_attrs.clone(); + entity_with_tags_and_attrs.set_uid(EntityUID::with_eid("entity_with_tags_and_attrs")); + #[cfg(feature = "entity-tags")] + entity_with_tags_and_attrs + .set_tag( + "spoon".into(), + RestrictedExpr::val(-121), + Extensions::none(), + ) + .unwrap(); + let mut child = Entity::with_uid(EntityUID::with_eid("child")); let mut parent = Entity::with_uid(EntityUID::with_eid("parent")); let grandparent = Entity::with_uid(EntityUID::with_eid("grandparent")); @@ -1003,10 +1091,13 @@ pub mod test { ); child_diff_type.add_ancestor(parent.uid().clone()); child_diff_type.add_ancestor(grandparent.uid().clone()); + Entities::from_entities( vec![ entity_no_attrs_no_parents, entity_with_attrs, + entity_with_tags, + entity_with_tags_and_attrs, child, child_diff_type, parent, @@ -1290,20 +1381,53 @@ pub mod test { )), Ok(Value::from(true)) ); - // get_attr on an attr which doesn't exist + // get_attr on an attr which doesn't exist (and no tags exist) assert_matches!( eval.interpret_inline_policy(&Expr::get_attr( Expr::val(EntityUID::with_eid("entity_with_attrs")), "doesnotexist".into() )), Err(EvaluationError::EntityAttrDoesNotExist(e)) => { + let report = miette::Report::new(e.clone()); assert_eq!(e.entity.as_ref(), &EntityUID::with_eid("entity_with_attrs")); - assert_eq!(&e.attr, "doesnotexist"); - let available_attrs = e.available_attrs; - assert_eq!(available_attrs.len(), 3); + assert_eq!(&e.attr_or_tag, "doesnotexist"); + let available_attrs = e.available_attrs_or_tags; + assert_eq!(available_attrs.len(), 4); assert!(available_attrs.contains(&"spoon".into())); assert!(available_attrs.contains(&"address".into())); assert!(available_attrs.contains(&"tags".into())); + expect_err( + "", + &report, + &ExpectedErrorMessageBuilder::error(r#"`test_entity_type::"entity_with_attrs"` does not have the attribute `doesnotexist`"#) + .help("available attributes: [address,fork,spoon,tags]") + .build() + ); + } + ); + // get_attr on an attr which doesn't exist (but the corresponding tag does) + assert_matches!( + eval.interpret_inline_policy(&Expr::get_attr( + Expr::val(EntityUID::with_eid("entity_with_tags")), + "spoon".into() + )), + Err(EvaluationError::EntityAttrDoesNotExist(e)) => { + let report = miette::Report::new(e.clone()); + assert_eq!(e.entity.as_ref(), &EntityUID::with_eid("entity_with_tags")); + assert_eq!(&e.attr_or_tag, "spoon"); + let available_attrs = e.available_attrs_or_tags; + assert_eq!(available_attrs.len(), 0); + #[cfg(feature = "entity-tags")] + let expected_error_message = + ExpectedErrorMessageBuilder::error(r#"`test_entity_type::"entity_with_tags"` does not have the attribute `spoon`"#) + .help(r#"`test_entity_type::"entity_with_tags"` does not have any attributes; note that a tag (not an attribute) named `spoon` does exist"#) + .build(); + #[cfg(not(feature = "entity-tags"))] + let expected_error_message = + ExpectedErrorMessageBuilder::error(r#"`test_entity_type::"entity_with_tags"` does not have the attribute `spoon`"#) + .help(r#"`test_entity_type::"entity_with_tags"` does not have any attributes"#) + .build(); + expect_err("", &report, &expected_error_message); } ); // get_attr on an attr which does exist (and has integer type) @@ -1318,7 +1442,7 @@ pub mod test { assert_eq!( eval.interpret_inline_policy(&Expr::contains( Expr::get_attr( - Expr::val(EntityUID::with_eid("entity_with_attrs")), + Expr::val(EntityUID::with_eid("entity_with_tags_and_attrs")), "tags".into() ), Expr::val("useful") @@ -1346,6 +1470,225 @@ pub mod test { ); } + #[test] + #[cfg(feature = "entity-tags")] + fn interpret_entity_tags() { + let request = basic_request(); + let entities = rich_entities(); + let eval = Evaluator::new(request, &entities, Extensions::none()); + // hasTag on an entity with no tags + assert_eq!( + eval.interpret_inline_policy(&Expr::has_tag( + Expr::val(EntityUID::with_eid("entity_no_attrs_no_parents")), + Expr::val("doesnotexist"), + )), + Ok(Value::from(false)) + ); + // hasTag on an entity that has tags, but not that one (and no attrs exist) + assert_eq!( + eval.interpret_inline_policy(&Expr::has_tag( + Expr::val(EntityUID::with_eid("entity_with_tags")), + Expr::val("doesnotexist"), + )), + Ok(Value::from(false)) + ); + // hasTag on an entity that has tags, but not that one (but does have an attr of that name) + assert_eq!( + eval.interpret_inline_policy(&Expr::has_tag( + Expr::val(EntityUID::with_eid("entity_with_tags_and_attrs")), + Expr::val("address"), + )), + Ok(Value::from(false)) + ); + // hasTag where the response is true + assert_eq!( + eval.interpret_inline_policy(&Expr::has_tag( + Expr::val(EntityUID::with_eid("entity_with_tags")), + Expr::val("spoon"), + )), + Ok(Value::from(true)) + ); + // hasTag, with a computed key, where the response is true + assert_eq!( + eval.interpret_inline_policy(&Expr::has_tag( + Expr::val(EntityUID::with_eid("entity_with_tags")), + Expr::get_attr( + Expr::val(EntityUID::with_eid("entity_with_tags_and_attrs")), + "fork".into() + ), + )), + Ok(Value::from(true)) + ); + // getTag on a tag which doesn't exist (and no attrs exist) + assert_matches!( + eval.interpret_inline_policy(&Expr::get_tag( + Expr::val(EntityUID::with_eid("entity_with_tags")), + Expr::val("doesnotexist"), + )), + Err(EvaluationError::EntityAttrDoesNotExist(e)) => { + let report = miette::Report::new(e.clone()); + assert_eq!(e.entity.as_ref(), &EntityUID::with_eid("entity_with_tags")); + assert_eq!(&e.attr_or_tag, "doesnotexist"); + let available_attrs = e.available_attrs_or_tags; + assert_eq!(available_attrs.len(), 1); + assert!(available_attrs.contains(&"spoon".into())); + expect_err( + "", + &report, + &ExpectedErrorMessageBuilder::error(r#"`test_entity_type::"entity_with_tags"` does not have the tag `doesnotexist`"#) + .help("available tags: [spoon]") + .build() + ); + } + ); + // getTag on a tag which doesn't exist (but the corresponding attr does) + assert_matches!( + eval.interpret_inline_policy(&Expr::get_tag( + Expr::val(EntityUID::with_eid("entity_with_tags_and_attrs")), + Expr::val("address"), + )), + Err(EvaluationError::EntityAttrDoesNotExist(e)) => { + let report = miette::Report::new(e.clone()); + assert_eq!(e.entity.as_ref(), &EntityUID::with_eid("entity_with_tags_and_attrs")); + assert_eq!(&e.attr_or_tag, "address"); + let available_attrs = e.available_attrs_or_tags; + assert_eq!(available_attrs.len(), 1); + assert!(available_attrs.contains(&"spoon".into())); + expect_err( + "", + &report, + &ExpectedErrorMessageBuilder::error(r#"`test_entity_type::"entity_with_tags_and_attrs"` does not have the tag `address`"#) + .help("available tags: [spoon]; note that an attribute (not a tag) named `address` does exist") + .build() + ); + } + ); + // getTag on a tag which does exist (and has integer type) + assert_eq!( + eval.interpret_inline_policy(&Expr::get_tag( + Expr::val(EntityUID::with_eid("entity_with_tags")), + Expr::val("spoon"), + )), + Ok(Value::from(-121)) + ); + // getTag with a computed key on a tag which does exist + assert_eq!( + eval.interpret_inline_policy(&Expr::get_tag( + Expr::val(EntityUID::with_eid("entity_with_tags")), + Expr::get_attr( + Expr::val(EntityUID::with_eid("entity_with_attrs")), + "fork".into() + ), + )), + Ok(Value::from(-121)) + ); + // getTag with a computed key on a tag which doesn't exist + assert_matches!( + eval.interpret_inline_policy(&Expr::get_tag( + Expr::val(EntityUID::with_eid("entity_with_tags")), + Expr::get_attr( + Expr::get_attr( + Expr::val(EntityUID::with_eid("entity_with_attrs")), + "address".into() + ), + "country".into() + ), + )), + Err(e) => { + expect_err( + "", + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error(r#"`test_entity_type::"entity_with_tags"` does not have the tag `amazonia`"#) + .help("available tags: [spoon]") + .build(), + ) + } + ); + // hasTag on an entity which doesn't exist + assert_eq!( + eval.interpret_inline_policy(&Expr::has_tag( + Expr::val(EntityUID::with_eid("doesnotexist")), + Expr::val("foo"), + )), + Ok(Value::from(false)) + ); + // getTag on an entity which doesn't exist + assert_eq!( + eval.interpret_inline_policy(&Expr::get_tag( + Expr::val(EntityUID::with_eid("doesnotexist")), + Expr::val("foo"), + )), + Err(EvaluationError::entity_does_not_exist( + Arc::new(EntityUID::with_eid("doesnotexist")), + None + )) + ); + // getTag on something that's not an entity + assert_matches!( + eval.interpret_inline_policy(&Expr::get_tag( + Expr::record([ + ("spoon".into(), Expr::val(78)), + ]).unwrap(), + Expr::val("spoon"), + )), + Err(e) => { + expect_err( + "", + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("type error: expected (entity of type `any_entity_type`), got record") + .build() + ); + } + ); + // hasTag on something that's not an entity + assert_matches!( + eval.interpret_inline_policy(&Expr::has_tag( + Expr::record([ + ("spoon".into(), Expr::val(78)), + ]).unwrap(), + Expr::val("spoon"), + )), + Err(e) => { + expect_err( + "", + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("type error: expected (entity of type `any_entity_type`), got record") + .build() + ); + } + ); + // getTag with a computed key that doesn't evaluate to a String + assert_matches!( + eval.interpret_inline_policy(&Expr::get_tag( + Expr::val(EntityUID::with_eid("entity_with_tags")), + Expr::get_attr(Expr::val(EntityUID::with_eid("entity_with_attrs")), "spoon".into()), + )), + Err(e) => { + expect_err( + "", + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("type error: expected string, got long") + .build() + ); + } + ); + // hasTag with a computed key that doesn't evaluate to a String + assert_matches!( + eval.interpret_inline_policy(&Expr::has_tag( + Expr::val(EntityUID::with_eid("entity_with_tags")), + Expr::get_attr(Expr::val(EntityUID::with_eid("entity_with_attrs")), "spoon".into()), + )), + Err(e) => { + expect_err( + "", + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("type error: expected string, got long") + .build() + ); + } + ); + } + #[test] fn interpret_ternaries() { let request = basic_request(); @@ -2078,6 +2421,8 @@ pub mod test { r#"Foo::"bar""#.parse().unwrap(), attrs.clone(), HashSet::new(), + #[cfg(feature = "entity-tags")] + [], Extensions::none(), ) .unwrap(); @@ -2100,6 +2445,7 @@ pub mod test { Arc::new(r#"Foo::"bar""#.parse().unwrap()), "foo".into(), expected_keys.iter(), + false, 7, None, ); diff --git a/cedar-policy-core/src/evaluator/err.rs b/cedar-policy-core/src/evaluator/err.rs index 4e1d80afe9..bad70cf3f2 100644 --- a/cedar-policy-core/src/evaluator/err.rs +++ b/cedar-policy-core/src/evaluator/err.rs @@ -23,7 +23,7 @@ use smol_str::SmolStr; use std::sync::Arc; use thiserror::Error; -// How many attrs will we store in an error before cutting off for performance reason +// How many attrs or tags will we store in an error before cutting off for performance reason const TOO_MANY_ATTRS: usize = 5; /// Enumeration of the possible errors that can occur during evaluation @@ -39,8 +39,8 @@ pub enum EvaluationError { #[diagnostic(transparent)] EntityDoesNotExist(#[from] evaluation_errors::EntityDoesNotExistError), - /// Tried to get an attribute, but the specified entity didn't - /// have that attribute + /// Tried to get an attribute or tag, but the specified entity didn't + /// have that attribute or tag #[error(transparent)] #[diagnostic(transparent)] EntityAttrDoesNotExist(#[from] evaluation_errors::EntityAttrDoesNotExistError), @@ -166,22 +166,55 @@ impl EvaluationError { } /// Construct a [`EntityAttrDoesNotExist`] error + /// + /// `does_attr_exist_as_a_tag`: does `attr` exist on `entity` as a tag (rather than an attribute) pub(crate) fn entity_attr_does_not_exist<'a>( entity: Arc, attr: SmolStr, available_attrs: impl IntoIterator, + does_attr_exist_as_a_tag: bool, total_attrs: usize, source_loc: Option, ) -> Self { evaluation_errors::EntityAttrDoesNotExistError { entity, - attr, - available_attrs: available_attrs + attr_or_tag: attr, + was_attr: true, + exists_the_other_kind: does_attr_exist_as_a_tag, + available_attrs_or_tags: available_attrs .into_iter() .take(TOO_MANY_ATTRS) .cloned() .collect::>(), - total_attrs, + total_attrs_or_tags: total_attrs, + source_loc, + } + .into() + } + + /// Construct an error for the case where an entity tag does not exist + /// + /// `does_tag_exist_as_an_attr`: does `tag` exist on `entity` as an attribute (rather than a tag) + #[cfg(feature = "entity-tags")] + pub(crate) fn entity_tag_does_not_exist<'a>( + entity: Arc, + tag: SmolStr, + available_tags: impl IntoIterator, + does_tag_exist_as_an_attr: bool, + total_tags: usize, + source_loc: Option, + ) -> Self { + evaluation_errors::EntityAttrDoesNotExistError { + entity, + attr_or_tag: tag, + was_attr: false, + exists_the_other_kind: does_tag_exist_as_an_attr, + available_attrs_or_tags: available_tags + .into_iter() + .take(TOO_MANY_ATTRS) + .cloned() + .collect::>(), + total_attrs_or_tags: total_tags, source_loc, } .into() @@ -340,16 +373,21 @@ pub mod evaluation_errors { // Don't make fields `pub`, don't make breaking changes, and use caution // when adding public methods. #[derive(Debug, PartialEq, Eq, Clone, Error)] - #[error("`{entity}` does not have the attribute `{attr}`")] + #[error("`{entity}` does not have the {} `{attr_or_tag}`", if *.was_attr { "attribute" } else { "tag" })] pub struct EntityAttrDoesNotExistError { /// Entity that didn't have the attribute pub(crate) entity: Arc, - /// Name of the attribute it didn't have - pub(crate) attr: SmolStr, - /// (First five) Available attributes on the entity - pub(crate) available_attrs: Vec, - /// Total number of attributes on the entity - pub(crate) total_attrs: usize, + /// Name of the attribute or tag it didn't have + pub(crate) attr_or_tag: SmolStr, + /// Whether this was an attempted attribute access (`true`) or tag access (`false`) + pub(crate) was_attr: bool, + /// If `true`, this is a case where we tried accessing an attribute but + /// there's a tag of that name, or vice versa + pub(crate) exists_the_other_kind: bool, + /// (First five) Available attributes/tags on the entity, depending on `was_attr` + pub(crate) available_attrs_or_tags: Vec, + /// Total number of attributes/tags on the entity, depending on `was_attr` + pub(crate) total_attrs_or_tags: usize, /// Source location pub(crate) source_loc: Option, } @@ -358,20 +396,43 @@ pub mod evaluation_errors { impl_diagnostic_from_source_loc_opt_field!(source_loc); fn help<'a>(&'a self) -> Option> { - if self.available_attrs.is_empty() { - Some(Box::new("entity does not have any attributes")) - } else if self.available_attrs.len() == self.total_attrs { - Some(Box::new(format!( - "Available attributes: {:?}", - self.available_attrs - ))) + let mut help_text = if self.available_attrs_or_tags.is_empty() { + format!( + "`{}` does not have any {}", + &self.entity, + if self.was_attr { "attributes" } else { "tags" } + ) + } else if self.available_attrs_or_tags.len() == self.total_attrs_or_tags { + format!( + "available {}: [{}]", + if self.was_attr { "attributes" } else { "tags" }, + self.available_attrs_or_tags.iter().join(",") + ) } else { - Some(Box::new(format!( - "available attributes: [{}, ... ({} more attributes) ]", - self.available_attrs.iter().join(","), - self.total_attrs - self.available_attrs.len() - ))) + format!( + "available {}: [{}, ... ({} more attributes) ]", + if self.was_attr { "attributes" } else { "tags" }, + self.available_attrs_or_tags.iter().join(","), + self.total_attrs_or_tags - self.available_attrs_or_tags.len() + ) + }; + if self.exists_the_other_kind { + help_text.push_str(&format!( + "; note that {} (not {}) named `{}` does exist", + if self.was_attr { + "a tag" + } else { + "an attribute" + }, + if self.was_attr { + "an attribute" + } else { + "a tag" + }, + self.attr_or_tag, + )); } + Some(Box::new(help_text)) } } diff --git a/cedar-policy-core/src/parser.rs b/cedar-policy-core/src/parser.rs index dd57f691bf..b0d1eb9ca9 100644 --- a/cedar-policy-core/src/parser.rs +++ b/cedar-policy-core/src/parser.rs @@ -308,7 +308,7 @@ pub(crate) mod test_utils { /// Expect that the given `ParseErrors` contains exactly one error, and that it matches the given `ExpectedErrorMessage`. /// - /// `src` is the original input text, just for better assertion-failure messages + /// `src` is the original input text (which the miette labels index into). #[track_caller] // report the caller's location as the location of the panic, not the location in this function pub fn expect_exactly_one_error(src: &str, errs: &ParseErrors, msg: &ExpectedErrorMessage<'_>) { match errs.len() { @@ -573,6 +573,7 @@ mod tests { ); } + /// Tests parser+evaluator with relations `<`, `<=`, `>`, `&&`, `||`, `!=` #[test] fn interpret_relation() { let request = eval::test::basic_request(); @@ -610,6 +611,81 @@ mod tests { ); } + /// Tests parser+evaluator with builtin methods `containsAll()`, `hasTag()`, `getTag()` + #[test] + fn interpret_methods() { + // The below tests check not only that we get the expected `Value`, but + // that it has the expected source location. + // See note on this in the above test. + + let src = r#" + [2, 3, "foo"].containsAll([3, "foo"]) + && principal.hasTag(resource.getTag(context.cur_time)) + "#; + #[cfg(feature = "entity-tags")] + { + let request = eval::test::basic_request(); + let entities = eval::test::basic_entities(); + let exts = Extensions::none(); + let evaluator = eval::Evaluator::new(request, &entities, exts); + + let expr = parse_expr(src).unwrap(); + assert_matches!(evaluator.interpret_inline_policy(&expr), Err(e) => { + expect_err( + src, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error(r#"`test_entity_type::"test_resource"` does not have the tag `03:22:11`"#) + .help(r#"`test_entity_type::"test_resource"` does not have any tags"#) + .exactly_one_underline("resource.getTag(context.cur_time)") + .build(), + ); + }); + } + #[cfg(not(feature = "entity-tags"))] + { + assert_matches!(parse_expr(src), Err(e) => { + expect_err( + src, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("entity tags are not supported in this build; to use entity tags, you must enable the `entity-tags` experimental feature") + .exactly_one_underline("resource.getTag(context.cur_time)") + .build(), + ); + }); + } + } + + #[test] + fn unquoted_tags() { + let src = r#" + principal.hasTag(foo) + "#; + assert_matches!(parse_expr(src), Err(e) => { + expect_err( + src, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("invalid variable: foo") + .help("the valid Cedar variables are `principal`, `action`, `resource`, and `context`; did you mean to enclose `foo` in quotes to make a string?") + .exactly_one_underline("foo") + .build(), + ); + }); + + let src = r#" + principal.getTag(foo) + "#; + assert_matches!(parse_expr(src), Err(e) => { + expect_err( + src, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("invalid variable: foo") + .help("the valid Cedar variables are `principal`, `action`, `resource`, and `context`; did you mean to enclose `foo` in quotes to make a string?") + .exactly_one_underline("foo") + .build(), + ); + }); + } + #[test] fn parse_exists() { let result = parse_policyset( @@ -621,6 +697,18 @@ mod tests { assert!(!result.expect("parse error").is_empty()); } + #[test] + fn attr_named_tags() { + let src = r#" + permit(principal, action, resource) + when { + resource.tags.contains({k: "foo", v: "bar"}) + }; + "#; + parse_policy_to_est_and_ast(None, src) + .unwrap_or_else(|e| panic!("{:?}", &miette::Report::new(e))); + } + #[test] fn test_parse_policyset() { use crate::ast::PolicyID; diff --git a/cedar-policy-core/src/parser/cst_to_ast.rs b/cedar-policy-core/src/parser/cst_to_ast.rs index 1ec58960a7..dbe9da1f26 100644 --- a/cedar-policy-core/src/parser/cst_to_ast.rs +++ b/cedar-policy-core/src/parser/cst_to_ast.rs @@ -459,6 +459,20 @@ impl ast::UnreservedId { .map(|arg| construct_method_contains_all(e, arg, loc.clone())), "containsAny" => extract_single_argument(args.into_iter(), "containsAny", loc) .map(|arg| construct_method_contains_any(e, arg, loc.clone())), + #[cfg(feature = "entity-tags")] + "getTag" => extract_single_argument(args.into_iter(), "getTag", loc) + .map(|arg| construct_method_getTag(e, arg, loc.clone())), + #[cfg(not(feature = "entity-tags"))] + "getTag" => { + Err(ToASTError::new(ToASTErrorKind::UnsupportedEntityTags, loc.clone()).into()) + } + #[cfg(feature = "entity-tags")] + "hasTag" => extract_single_argument(args.into_iter(), "hasTag", loc) + .map(|arg| construct_method_hasTag(e, arg, loc.clone())), + #[cfg(not(feature = "entity-tags"))] + "hasTag" => { + Err(ToASTError::new(ToASTErrorKind::UnsupportedEntityTags, loc.clone()).into()) + } _ => { if EXTENSION_STYLES.methods.contains(self) { let args = NonEmpty { @@ -1514,7 +1528,10 @@ impl ast::Name { if self.0.path.is_empty() { let id = self.basename(); if EXTENSION_STYLES.methods.contains(&id) - || matches!(id.as_ref(), "contains" | "containsAll" | "containsAny") + || matches!( + id.as_ref(), + "contains" | "containsAll" | "containsAny" | "getTag" | "hasTag" + ) { return Err(ToASTError::new( ToASTErrorKind::FunctionCallOnMethod(self.basename()), @@ -1797,6 +1814,14 @@ fn construct_method_contains_any(e0: ast::Expr, e1: ast::Expr, loc: Loc) -> ast: .with_source_loc(loc) .contains_any(e0, e1) } +#[cfg(feature = "entity-tags")] +fn construct_method_getTag(e0: ast::Expr, e1: ast::Expr, loc: Loc) -> ast::Expr { + ast::ExprBuilder::new().with_source_loc(loc).get_tag(e0, e1) +} +#[cfg(feature = "entity-tags")] +fn construct_method_hasTag(e0: ast::Expr, e1: ast::Expr, loc: Loc) -> ast::Expr { + ast::ExprBuilder::new().with_source_loc(loc).has_tag(e0, e1) +} fn construct_ext_meth(n: UnreservedId, args: NonEmpty, loc: Loc) -> ast::Expr { let name = ast::Name::unqualified_name(n); @@ -2228,7 +2253,7 @@ mod tests { ); assert_matches!( policy.annotation(&ast::AnyId::new_unchecked("anno")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "good annotation") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "good annotation") ); // duplication is error @@ -2278,28 +2303,28 @@ mod tests { .get(&ast::PolicyID::from_string("policy0")) .expect("should be a policy") .annotation(&ast::AnyId::new_unchecked("anno1")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "first") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "first") ); assert_matches!( policyset .get(&ast::PolicyID::from_string("policy1")) .expect("should be a policy") .annotation(&ast::AnyId::new_unchecked("anno2")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "second") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "second") ); assert_matches!( policyset .get(&ast::PolicyID::from_string("policy2")) .expect("should be a policy") .annotation(&ast::AnyId::new_unchecked("anno3a")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "third-a") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "third-a") ); assert_matches!( policyset .get(&ast::PolicyID::from_string("policy2")) .expect("should be a policy") .annotation(&ast::AnyId::new_unchecked("anno3b")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "third-b") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "third-b") ); assert_matches!( policyset @@ -2340,43 +2365,43 @@ mod tests { .expect("should be the right policy ID"); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("if")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `if`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `if`") ); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("then")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `then`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `then`") ); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("else")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `else`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `else`") ); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("true")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `true`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `true`") ); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("false")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `false`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `false`") ); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("in")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `in`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `in`") ); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("is")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `is`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `is`") ); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("like")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `like`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `like`") ); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("has")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `has`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `has`") ); assert_matches!( policy0.annotation(&ast::AnyId::new_unchecked("principal")), - Some(ast::Annotation { val, .. }) => assert_eq!(val.as_ref(), "this is the annotation for `principal`") + Some(ast::Annotation { val, .. }) => assert_eq!(val.as_str(), "this is the annotation for `principal`") ); } diff --git a/cedar-policy-core/src/parser/err.rs b/cedar-policy-core/src/parser/err.rs index 487e383606..462e906205 100644 --- a/cedar-policy-core/src/parser/err.rs +++ b/cedar-policy-core/src/parser/err.rs @@ -371,6 +371,10 @@ pub enum ToASTErrorKind { #[error("when `is` and `in` are used together, `is` must come first")] #[diagnostic(help("try `_ is _ in _`"))] InvertedIsIn, + /// Returned when a policy uses entity tags, but the `entity-tags` feature is not enabled + #[cfg(not(feature = "entity-tags"))] + #[error("entity tags are not supported in this build; to use entity tags, you must enable the `entity-tags` experimental feature")] + UnsupportedEntityTags, } impl ToASTErrorKind { diff --git a/cedar-policy-core/src/parser/grammar.lalrpop b/cedar-policy-core/src/parser/grammar.lalrpop index e8a374f63c..72e4ded492 100644 --- a/cedar-policy-core/src/parser/grammar.lalrpop +++ b/cedar-policy-core/src/parser/grammar.lalrpop @@ -32,7 +32,6 @@ extern { type Error = RawUserError; } -// New tokens should be reflected in the `FRIENDLY_TOKEN_NAMES` map in err.rs. match { // Whitespace and comments r"\s*" => { }, // The default whitespace skipping is disabled an `ignore pattern` is specified diff --git a/cedar-policy-formatter/Cargo.toml b/cedar-policy-formatter/Cargo.toml index 53c89450ca..08da032b1c 100644 --- a/cedar-policy-formatter/Cargo.toml +++ b/cedar-policy-formatter/Cargo.toml @@ -15,7 +15,7 @@ cedar-policy-core = { version = "=4.0.0", path = "../cedar-policy-core" } pretty = "0.12.1" logos = "0.14.0" itertools = "0.13" -smol_str = { version = "0.2", features = ["serde"] } +smol_str = { version = "0.3", features = ["serde"] } regex = { version= "1.9.1", features = ["unicode"] } miette = { version = "7.1.0" } lazy_static = "1.4.0" diff --git a/cedar-policy-formatter/src/pprint/fmt.rs b/cedar-policy-formatter/src/pprint/fmt.rs index bae295cb65..4b1f04ad03 100644 --- a/cedar-policy-formatter/src/pprint/fmt.rs +++ b/cedar-policy-formatter/src/pprint/fmt.rs @@ -109,9 +109,13 @@ pub fn policies_str_to_pretty(ps: &str, config: &Config) -> Result { .map(|p| Ok(remove_empty_lines(&tree_to_pretty(p, &mut context)?))) .collect::>>()? .join("\n\n"); + + // add a trailing newline + formatted_policies.push('\n'); + // handle comment at the end of a policyset if !end_of_file_comment.is_empty() { - formatted_policies.push('\n'); + // note: end_of_file_comment is guaranteed to end with a newline formatted_policies.push_str(&end_of_file_comment); } @@ -172,6 +176,42 @@ mod tests { assert!(soundness_check(p2, &parse_policyset(p1).unwrap()).is_ok()); } + #[test] + fn test_add_trailing_newline() { + // The formatter should add a trailing newline. + // This behavior isn't tested by the snapshots below because `insta` + // ignores trailing whitespace. + + let config = Config { + line_width: 80, + indent_width: 2, + }; + + let formatted_p = "permit (principal, action, resource);\n"; + let p1 = "permit (principal, action, resource);"; + let p2 = "permit (principal, action, resource);\r\n"; + let p3 = "permit (principal, action, resource);\n\r\n\n"; + + assert_eq!( + policies_str_to_pretty(formatted_p, &config).unwrap(), + formatted_p + ); + assert_eq!(policies_str_to_pretty(p1, &config).unwrap(), formatted_p); + assert_eq!(policies_str_to_pretty(p2, &config).unwrap(), formatted_p); + assert_eq!(policies_str_to_pretty(p3, &config).unwrap(), formatted_p); + + let formatted_p = "permit (principal, action, resource);\n//foo\n"; + let p1 = "permit (principal, action, resource);\n//foo"; + let p2 = "permit (principal, action, resource);\n//foo\n\n\n"; + + assert_eq!( + policies_str_to_pretty(formatted_p, &config).unwrap(), + formatted_p + ); + assert_eq!(policies_str_to_pretty(p1, &config).unwrap(), formatted_p); + assert_eq!(policies_str_to_pretty(p2, &config).unwrap(), formatted_p); + } + #[test] fn test_format_files() { let config = Config { diff --git a/cedar-policy-validator/Cargo.toml b/cedar-policy-validator/Cargo.toml index efbfabfc2d..511d6b4dbd 100644 --- a/cedar-policy-validator/Cargo.toml +++ b/cedar-policy-validator/Cargo.toml @@ -20,7 +20,7 @@ thiserror = "1.0" itertools = "0.13" ref-cast = "1.0" unicode-security = "0.1.0" -smol_str = { version = "0.2", features = ["serde"] } +smol_str = { version = "0.3", features = ["serde"] } stacker = "0.1.15" arbitrary = { version = "1", features = ["derive"], optional = true } lalrpop-util = { version = "0.21.0", features = ["lexer", "unicode"] } @@ -46,6 +46,7 @@ arbitrary = ["dep:arbitrary", "cedar-policy-core/arbitrary"] # Experimental features. partial-validate = [] wasm = ["serde-wasm-bindgen", "tsify", "wasm-bindgen"] +entity-tags = [] [dev-dependencies] similar-asserts = "1.5.0" diff --git a/cedar-policy-validator/src/cedar_schema/ast.rs b/cedar-policy-validator/src/cedar_schema/ast.rs index 5fd30a9319..6bac99ddcc 100644 --- a/cedar-policy-validator/src/cedar_schema/ast.rs +++ b/cedar-policy-validator/src/cedar_schema/ast.rs @@ -216,6 +216,8 @@ pub struct EntityDecl { pub member_of_types: Vec, /// Attributes this entity has pub attrs: Vec>, + /// Tag type for this entity (`None` means no tags on this entity) + pub tags: Option>, } /// Type definitions @@ -251,28 +253,15 @@ impl From for json_schema::TypeVariant { } /// Attribute declarations, used in records and entity types. -/// One [`AttrDecl`] is one key-value pair, or an embedded attribute map pair `?: value_ty`. -/// -/// The data structures in this file, and the Cedar schema format parser in -/// general, permissively allow `EAMap`s to appear anywhere an [`AttrDecl`] is -/// expected (including inside other `EAMap`s), in order to provide better error -/// messages when `EAMap`s are encountered in illegal but plausible positions +/// One [`AttrDecl`] is one key-value pair. #[derive(Debug, Clone)] -pub enum AttrDecl { - /// A normal attribute declaration `name: ty` or `name?: ty` - Concrete { - /// Name of this attribute - name: Node, - /// Whether or not it is a required attribute (default `true`) - required: bool, - /// The type of this attribute - ty: Node, - }, - /// An `EAMap` declaration `?: ty` - EAMap { - /// Value type of the `EAMap` - value_ty: Node, - }, +pub struct AttrDecl { + /// Name of this attribute + pub name: Node, + /// Whether or not it is a required attribute (default `true`) + pub required: bool, + /// The type of this attribute + pub ty: Node, } /// The target of a [`PRAppDecl`] diff --git a/cedar-policy-validator/src/cedar_schema/err.rs b/cedar-policy-validator/src/cedar_schema/err.rs index 99d838a323..a385abc0be 100644 --- a/cedar-policy-validator/src/cedar_schema/err.rs +++ b/cedar-policy-validator/src/cedar_schema/err.rs @@ -72,6 +72,7 @@ lazy_static! { ("TYPE", "`type`"), ("SET", "`Set`"), ("IDENTIFIER", "identifier"), + ("TAGS", "`tags`"), ]), impossible_tokens: HashSet::new(), special_identifier_tokens: HashSet::from([ @@ -85,6 +86,7 @@ lazy_static! { "RESOURCE", "CONTEXT", "ATTRIBUTES", + "TAGS", "LONG", "STRING", "BOOL", @@ -298,12 +300,6 @@ impl From for ToJsonSchemaErrors { } } -impl From for ToJsonSchemaErrors { - fn from(e: EAMapError) -> Self { - Self::from(ToJsonSchemaError::from(e)) - } -} - impl Display for ToJsonSchemaErrors { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0.first()) // intentionally showing only the first error; see #326 for discussion on a similar error type @@ -405,10 +401,6 @@ pub enum ToJsonSchemaError { #[error(transparent)] #[diagnostic(transparent)] ReservedSchemaKeyword(#[from] ReservedSchemaKeyword), - /// Errors relating to embedded attribute maps - #[error(transparent)] - #[diagnostic(transparent)] - EAMap(#[from] EAMapError), } impl ToJsonSchemaError { @@ -521,86 +513,6 @@ impl Diagnostic for ReservedName { } } -/// An embedded attribute map (RFC 68) was encountered where one is not allowed -#[derive(Debug, Clone, Error, PartialEq, Eq)] -#[error("found an embedded attribute map type, but embedded attribute maps are not allowed in this position")] -pub struct EAMapNotAllowedHereError { - /// Source location of the `EAMap` - pub(crate) source_loc: Loc, - /// Context-dependent help text - pub(crate) help: Option, -} - -impl Diagnostic for EAMapNotAllowedHereError { - impl_diagnostic_from_source_loc_field!(source_loc); - - fn help<'a>(&'a self) -> Option> { - self.help.as_ref().map(|help| Box::new(help) as _) - } -} - -#[derive(Debug, Clone, Error, PartialEq, Eq)] -pub(crate) enum EAMapNotAllowedHereHelp { - #[error("Embedded attribute maps are not allowed as the top-level descriptor of all attributes of an entity. Try making an entity attribute to hold the embedded attribute map. E.g., `attributes: {{ ?: someType }}`.")] - TopLevelEAMap, -} - -/// Encountered a type like `{ foo: Long, ?: String }` that mixes concrete attributes with an `EAMap`. -/// This is currently not allowed. -#[derive(Debug, Clone, Error, PartialEq, Eq)] -#[error("this type contains both concrete attributes and an embedded attribute map (`?:`), which is not allowed")] -pub struct EAMapWithConcreteAttributesError { - /// Source location of the `EAMap` declaration - pub(crate) source_loc: Loc, -} - -impl Diagnostic for EAMapWithConcreteAttributesError { - impl_diagnostic_from_source_loc_field!(source_loc); -} - -/// Encountered a type like `{ ?: Long, ?: String }` that mixes two or more `EAMap` declarations. -/// This is currently not allowed. -#[derive(Debug, Clone, Error, PartialEq, Eq)] -#[error("this type contains two or more different embedded attribute map declarations (`?:`), which is not allowed")] -pub struct MultipleEAMapDeclarationsError { - /// Source location of the first `EAMap` declaration - pub(crate) source_loc_1: Loc, - /// Source location of the second `EAMap` declaration - pub(crate) source_loc_2: Loc, -} - -impl Diagnostic for MultipleEAMapDeclarationsError { - impl_diagnostic_from_two_source_loc_fields!(source_loc_1, source_loc_2); - - fn help<'a>(&'a self) -> Option> { - Some(Box::new( - "try separating this into two different attributes", - )) - } -} - -/// Errors relating to embedded attribute maps (`EAMap`s) -// -// This is NOT a publicly exported error type. -#[derive(Debug, Clone, Diagnostic, Error, PartialEq, Eq)] -#[non_exhaustive] -pub enum EAMapError { - /// An embedded attribute map (RFC 68) was encountered where one is not allowed - #[error(transparent)] - #[diagnostic(transparent)] - NotAllowedHere(#[from] EAMapNotAllowedHereError), - /// Encountered a type like `{ foo: Long, ?: String }` that mixes concrete attributes with an `EAMap`. - /// This is currently not allowed. - #[error(transparent)] - #[diagnostic(transparent)] - WithConcreteAttributes(#[from] EAMapWithConcreteAttributesError), - /// Encountered a type like `{ ?: Long, ?: String }` that mixes two or more `EAMap` declarations. - /// This is currently not allowed. - #[error(transparent)] - #[diagnostic(transparent)] - MultipleEAMapDeclarations(#[from] MultipleEAMapDeclarationsError), -} - #[derive(Debug, Clone, PartialEq, Eq, Error)] #[error("unknown type name: `{name}`")] pub struct UnknownTypeName { diff --git a/cedar-policy-validator/src/cedar_schema/fmt.rs b/cedar-policy-validator/src/cedar_schema/fmt.rs index 78c3e627e7..40b4f90a2a 100644 --- a/cedar-policy-validator/src/cedar_schema/fmt.rs +++ b/cedar-policy-validator/src/cedar_schema/fmt.rs @@ -74,7 +74,7 @@ impl Display for json_schema::Type { } } -impl Display for json_schema::RecordType> { +impl Display for json_schema::RecordType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{{")?; for (i, (n, ty)) in self.attributes.iter().enumerate() { @@ -94,37 +94,6 @@ impl Display for json_schema::RecordType Display for json_schema::RecordType> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{{")?; - for (i, (n, ty)) in self.attributes.iter().enumerate() { - write!( - f, - "\"{}\"{}: {}", - n.escape_debug(), - if ty.required { "" } else { "?" }, - ty.ty - )?; - if i < (self.attributes.len() - 1) { - write!(f, ", ")?; - } - } - write!(f, "}}")?; - Ok(()) - } -} - -impl Display for json_schema::EntityAttributeTypeInternal { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - json_schema::EntityAttributeTypeInternal::Type(ty) => ty.fmt(f), - json_schema::EntityAttributeTypeInternal::EAMap { value_type } => { - write!(f, "{{ ?: {value_type} }}") - } - } - } -} - /// Create a non-empty with borrowed contents from a slice fn non_empty_slice(v: &[T]) -> Option> { let vs: Vec<&T> = v.iter().collect(); diff --git a/cedar-policy-validator/src/cedar_schema/grammar.lalrpop b/cedar-policy-validator/src/cedar_schema/grammar.lalrpop index 72d41704b3..42de91c221 100644 --- a/cedar-policy-validator/src/cedar_schema/grammar.lalrpop +++ b/cedar-policy-validator/src/cedar_schema/grammar.lalrpop @@ -51,7 +51,6 @@ extern { type Error = RawUserError; } -// New tokens should be reflected in the `FRIENDLY_TOKEN_NAMES` map in err.rs. match { // Whitespace and comments r"\s*" => { }, // The default whitespace skipping is disabled an `ignore pattern` is specified @@ -69,6 +68,7 @@ match { "resource" => RESOURCE, "context" => CONTEXT, "attributes" => ATTRIBUTES, + "tags" => TAGS, "Long" => LONG, "String" => STRING, "Bool" => BOOL, @@ -108,8 +108,8 @@ Decl: Node = { // Entity := 'entity' Idents ['in' EntOrTypes] [['='] RecType] ';' Entity: Node = { - ENTITY )?> "}")?> ";" - => Node::with_source_loc(Declaration::Entity(EntityDecl { names: ets, member_of_types: ps.unwrap_or_default(), attrs: ds.map(|ds| ds.unwrap_or_default()).unwrap_or_default()}), Loc::new(l..r, Arc::clone(src))), + ENTITY )?> "}")?> )?> ";" + => Node::with_source_loc(Declaration::Entity(EntityDecl { names: ets, member_of_types: ps.unwrap_or_default(), attrs: ds.map(|ds| ds.unwrap_or_default()).unwrap_or_default(), tags: ts }), Loc::new(l..r, Arc::clone(src))), } // Action := 'action' Names ['in' QualNameOrNames] @@ -183,18 +183,15 @@ pub Type: Node = { => Node::with_source_loc(SType::Record(ds.unwrap_or_default()), Loc::new(l..r, Arc::clone(src))), } -// AttrDecls := [Name ['?'] | '?'] ':' Type [',' | ',' AttrDecls] +// AttrDecls := Name ['?'] ':' Type [',' | ',' AttrDecls] AttrDecls: Vec> = { ":" ","? - => vec![Node::with_source_loc(AttrDecl::Concrete { name, required: required.is_none(), ty}, Loc::new(l..r, Arc::clone(src)))], + => vec![Node::with_source_loc(AttrDecl { name, required: required.is_none(), ty}, Loc::new(l..r, Arc::clone(src)))], ":" "," - => {ds.insert(0, Node::with_source_loc(AttrDecl::Concrete { name, required: required.is_none(), ty}, Loc::new(l..r, Arc::clone(src)))); ds}, - "?" ":" ","? - => vec![Node::with_source_loc(AttrDecl::EAMap { value_ty }, Loc::new(l..r, Arc::clone(src)))], - "?" ":" "," - => {ds.insert(0, Node::with_source_loc(AttrDecl::EAMap { value_ty }, Loc::new(l..r, Arc::clone(src)))); ds}, + => {ds.insert(0, Node::with_source_loc(AttrDecl { name, required: required.is_none(), ty}, Loc::new(l..r, Arc::clone(src)))); ds}, } + Comma: Vec = { => e.into_iter().collect(), ",")+> => { @@ -223,6 +220,8 @@ Ident: Node = { => Node::with_source_loc("context".parse().unwrap(), Loc::new(l..r, Arc::clone(src))), ATTRIBUTES => Node::with_source_loc("attributes".parse().unwrap(), Loc::new(l..r, Arc::clone(src))), + TAGS + => Node::with_source_loc("tags".parse().unwrap(), Loc::new(l..r, Arc::clone(src))), BOOL => Node::with_source_loc("Bool".parse().unwrap(), Loc::new(l..r, Arc::clone(src))), LONG diff --git a/cedar-policy-validator/src/cedar_schema/test.rs b/cedar-policy-validator/src/cedar_schema/test.rs index cfadf13ec6..1e8e172a1e 100644 --- a/cedar-policy-validator/src/cedar_schema/test.rs +++ b/cedar-policy-validator/src/cedar_schema/test.rs @@ -14,41 +14,10 @@ * limitations under the License. */ -#![cfg(test)] - -#[cfg(test)] -pub use test_utils::*; - -#[cfg(test)] -mod test_utils { - use crate::json_schema; - - #[allow(dead_code)] - #[track_caller] - pub fn assert_record_attr_has_type( - e: &json_schema::RecordAttributeType, - expected: &json_schema::Type, - ) { - assert!(e.required); - assert_eq!(&e.ty, expected); - } - - #[track_caller] - pub fn assert_entity_attr_has_type( - e: &json_schema::EntityAttributeType, - expected: &json_schema::EntityAttributeTypeInternal, - ) { - assert!(e.required); - assert_eq!(&e.ty, expected); - } -} - // PANIC SAFETY: unit tests #[allow(clippy::panic)] #[cfg(test)] mod demo_tests { - use super::*; - use std::{ collections::HashMap, iter::{empty, once}, @@ -66,7 +35,7 @@ mod demo_tests { err::{ToJsonSchemaError, NO_PR_HELP_MSG}, }, json_schema, - schema::test::collect_warnings, + schema::test::utils::collect_warnings, CedarSchemaError, RawName, }; @@ -466,7 +435,8 @@ namespace Baz {action "Foo" appliesTo { "a".parse().unwrap(), json_schema::EntityType:: { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, )]), actions: HashMap::from([( @@ -476,7 +446,7 @@ namespace Baz {action "Foo" appliesTo { applies_to: Some(json_schema::ApplySpec:: { resource_types: vec![], principal_types: vec!["a".parse().unwrap()], - context: json_schema::RecordOrContextAttributes::default(), + context: json_schema::AttributesOrContext::default(), }), member_of: None, }, @@ -591,17 +561,16 @@ namespace Baz {action "Foo" appliesTo { assert!(repo.member_of_types.is_empty()); let groups = ["readers", "writers", "triagers", "admins", "maintainers"]; for group in groups { - assert_matches!(&repo.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs: json_schema::RecordType { + assert_matches!(&repo.shape, json_schema::AttributesOrContext(json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes, additional_attributes: false, - }, .. }) => { - let attribute = attributes.get(group).expect("No attribute `{group}`"); - assert_entity_attr_has_type( - attribute, - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + }))) => { + let expected = + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "UserGroup".parse().unwrap(), - })), - ); + }); + let attribute = attributes.get(group).expect("No attribute `{group}`"); + assert_has_type(attribute, expected); }); } let issue = github @@ -609,23 +578,23 @@ namespace Baz {action "Foo" appliesTo { .get(&"Issue".parse().unwrap()) .expect("No `Issue`"); assert!(issue.member_of_types.is_empty()); - assert_matches!(&issue.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs: json_schema::RecordType { + assert_matches!(&issue.shape, json_schema::AttributesOrContext(json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes, additional_attributes: false, - }, .. }) => { + }))) => { let attribute = attributes.get("repo").expect("No `repo`"); - assert_entity_attr_has_type( + assert_has_type( attribute, - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "Repository".parse().unwrap(), - })), + }), ); let attribute = attributes.get("reporter").expect("No `repo`"); - assert_entity_attr_has_type( + assert_has_type( attribute, - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "User".parse().unwrap(), - })), + }), ); }); let org = github @@ -635,21 +604,28 @@ namespace Baz {action "Foo" appliesTo { assert!(org.member_of_types.is_empty()); let groups = ["members", "owners", "memberOfTypes"]; for group in groups { - assert_matches!(&org.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs: json_schema::RecordType { + assert_matches!(&org.shape, json_schema::AttributesOrContext(json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes, additional_attributes: false, - }, .. }) => { + }))) => { + let expected = json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + type_name: "UserGroup".parse().unwrap(), + }); let attribute = attributes.get(group).expect("No attribute `{group}`"); - assert_entity_attr_has_type( - attribute, - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { - type_name: "UserGroup".parse().unwrap(), - })), - ); + assert_has_type(attribute, expected); }); } } + #[track_caller] + fn assert_has_type( + e: &json_schema::TypeOfAttribute, + expected: json_schema::Type, + ) { + assert!(e.required); + assert_eq!(&e.ty, &expected); + } + #[track_caller] fn assert_empty_record(etyp: &json_schema::EntityType) { assert!(etyp.shape.is_empty_record()); @@ -691,23 +667,23 @@ namespace Baz {action "Foo" appliesTo { .get(&"User".parse().unwrap()) .expect("No `User`"); assert_eq!(&user.member_of_types, &vec!["Group".parse().unwrap()]); - assert_matches!(&user.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs: json_schema::RecordType { + assert_matches!(&user.shape, json_schema::AttributesOrContext(json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes, additional_attributes: false, - }, .. }) => { - assert_entity_attr_has_type( + }))) => { + assert_has_type( attributes.get("personalGroup").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "Group".parse().unwrap(), - })), + }), ); - assert_entity_attr_has_type( + assert_has_type( attributes.get("blocked").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::Set { + json_schema::Type::Type(json_schema::TypeVariant::Set { element: Box::new(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "User".parse().unwrap(), })), - })), + }), ); }); let group = doccloud @@ -718,15 +694,15 @@ namespace Baz {action "Foo" appliesTo { &group.member_of_types, &vec!["DocumentShare".parse().unwrap()] ); - assert_matches!(&group.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs: json_schema::RecordType { + assert_matches!(&group.shape, json_schema::AttributesOrContext(json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes, additional_attributes: false, - }, .. }) => { - assert_entity_attr_has_type( + }))) => { + assert_has_type( attributes.get("owner").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "User".parse().unwrap(), - })), + }), ); }); let document = doccloud @@ -734,45 +710,45 @@ namespace Baz {action "Foo" appliesTo { .get(&"Document".parse().unwrap()) .expect("No `Group`"); assert!(document.member_of_types.is_empty()); - assert_matches!(&document.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs: json_schema::RecordType { + assert_matches!(&document.shape, json_schema::AttributesOrContext(json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes, additional_attributes: false, - }, .. }) => { - assert_entity_attr_has_type( + }))) => { + assert_has_type( attributes.get("owner").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "User".parse().unwrap(), - })), + }), ); - assert_entity_attr_has_type( + assert_has_type( attributes.get("isPrivate").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "Bool".parse().unwrap(), - })), + }), ); - assert_entity_attr_has_type( + assert_has_type( attributes.get("publicAccess").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "String".parse().unwrap(), - })), + }), ); - assert_entity_attr_has_type( + assert_has_type( attributes.get("viewACL").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "DocumentShare".parse().unwrap(), - })), + }), ); - assert_entity_attr_has_type( + assert_has_type( attributes.get("modifyACL").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "DocumentShare".parse().unwrap(), - })), + }), ); - assert_entity_attr_has_type( + assert_has_type( attributes.get("manageACL").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "DocumentShare".parse().unwrap(), - })), + }), ); }); let document_share = doccloud @@ -906,15 +882,15 @@ namespace Baz {action "Foo" appliesTo { .entity_types .get(&"Resource".parse().unwrap()) .unwrap(); - assert_matches!(&resource.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs: json_schema::RecordType { + assert_matches!(&resource.shape, json_schema::AttributesOrContext(json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes, additional_attributes: false, - }, .. }) => { - assert_matches!(attributes.get("tag"), Some(json_schema::EntityAttributeType { ty: json_schema::EntityAttributeTypeInternal::Type(ty), required: true }) => { + }))) => { + assert_matches!(attributes.get("tag"), Some(json_schema::TypeOfAttribute { ty, required: true }) => { assert_matches!(ty, json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name }) => { assert_eq!(type_name, &"AWS::Tag".parse().unwrap()); }); - }) + }); }); } @@ -941,7 +917,7 @@ namespace Baz {action "Foo" appliesTo { assert_labeled_span("type t =", "expected `{`, identifier, or `Set`"); assert_labeled_span( "entity User {", - "expected `?`, `}`, identifier, or string literal", + "expected `}`, identifier, or string literal", ); assert_labeled_span("entity User { name:", "expected `{`, identifier, or `Set`"); } @@ -1180,8 +1156,6 @@ mod parser_tests { #[allow(clippy::panic)] #[cfg(test)] mod translator_tests { - use super::*; - use cedar_policy_core::ast as cedar_ast; use cedar_policy_core::extensions::Extensions; use cedar_policy_core::test_utils::{expect_err, ExpectedErrorMessageBuilder}; @@ -1194,7 +1168,7 @@ mod translator_tests { to_json_schema::cedar_schema_to_json_schema, }, json_schema, - schema::test::collect_warnings, + schema::test::utils::collect_warnings, types::{EntityLUB, EntityRecordKind, Primitive, Type}, ValidatorSchema, }; @@ -1444,22 +1418,22 @@ mod translator_tests { json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available()).unwrap(); let demo = frag.0.get(&Some("Demo".parse().unwrap())).unwrap(); let user = demo.entity_types.get(&"User".parse().unwrap()).unwrap(); - assert_matches!(&user.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs: json_schema::RecordType { + assert_matches!(&user.shape, json_schema::AttributesOrContext(json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes, additional_attributes: false, - }, .. }) => { - assert_entity_attr_has_type( - attributes.get("name").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + }))) => { + assert_matches!(attributes.get("name"), Some(json_schema::TypeOfAttribute { ty, required: true }) => { + let expected = json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "id".parse().unwrap(), - })), - ); - assert_entity_attr_has_type( - attributes.get("email").unwrap(), - &json_schema::EntityAttributeTypeInternal::Type(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + }); + assert_eq!(ty, &expected); + }); + assert_matches!(attributes.get("email"), Some(json_schema::TypeOfAttribute { ty, required: true }) => { + let expected = json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "email_address".parse().unwrap(), - })), - ); + }); + assert_eq!(ty, &expected); + }); }); assert_matches!(ValidatorSchema::try_from(frag), Err(e) => { expect_err( @@ -2324,163 +2298,73 @@ mod common_type_references { } } -/// Tests involving embedded attribute maps (RFC 68) +/// Tests involving entity tags (RFC 82) #[cfg(test)] -mod ea_maps { - use super::assert_entity_attr_has_type; +mod entity_tags { use crate::json_schema; - use crate::schema::test::collect_warnings; + use crate::schema::test::utils::collect_warnings; use cedar_policy_core::extensions::Extensions; - use cedar_policy_core::test_utils::{expect_err, ExpectedErrorMessageBuilder}; use cool_asserts::assert_matches; #[test] - fn entity_attribute() { - let src = "entity E { tags: { ?: Set } };"; + fn basic_examples() { + let src = "entity E;"; assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Ok((frag, warnings)) => { assert!(warnings.is_empty()); let entity_type = frag.0.get(&None).unwrap().entity_types.get(&"E".parse().unwrap()).unwrap(); - assert_matches!(&entity_type.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs, .. }) => { - assert_entity_attr_has_type( - attrs.attributes.get("tags").unwrap(), - &json_schema::EntityAttributeTypeInternal::EAMap { - value_type: json_schema::Type::Type(json_schema::TypeVariant::Set { - element: Box::new(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "String".parse().unwrap() })), - }), - }, - ); - }); - }); - } - - #[test] - fn record_attribute_inside_entity_attribute() { - let src = "entity E { tags: { foo: { ?: Set } } };"; - assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Err(e) => { - expect_err( - src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("error parsing schema: found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .exactly_one_underline("?: Set") - .build(), - ); - }); - } - - #[test] - fn context_attribute() { - let src = "entity E; action read appliesTo { principal: [E], resource: [E], context: { operationDetails: { ?: String } } };"; - assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Err(e) => { - expect_err( - src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("error parsing schema: found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .exactly_one_underline("?: String") - .build(), - ); - }); - } - - #[test] - fn toplevel_entity() { - let src = "entity E { ?: Set };"; - assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Err(e) => { - expect_err( - src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("error parsing schema: found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .help("Embedded attribute maps are not allowed as the top-level descriptor of all attributes of an entity. Try making an entity attribute to hold the embedded attribute map. E.g., `attributes: { ?: someType }`.") - .exactly_one_underline("?: Set") - .build(), - ); + assert_matches!(&entity_type.tags, None); }); - } - #[test] - fn toplevel_context() { - let src = "entity E; action read appliesTo { principal: [E], resource: [E], context: { ?: String } };"; - assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Err(e) => { - expect_err( - src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("error parsing schema: found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .exactly_one_underline("?: String") - .build(), - ); - }); - } - - #[test] - fn common_type() { - let src = "type blah = { ?: String }; entity User { blah: blah };"; - assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Err(e) => { - expect_err( - src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("error parsing schema: found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .exactly_one_underline("?: String") - .build(), - ); + let src = "entity E tags String;"; + assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Ok((frag, warnings)) => { + assert!(warnings.is_empty()); + let entity_type = frag.0.get(&None).unwrap().entity_types.get(&"E".parse().unwrap()).unwrap(); + assert_matches!(&entity_type.tags, Some(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name })) => { + assert_eq!(&format!("{type_name}"), "String"); + }); }); - } - #[test] - fn value_type_is_common_type() { - let src = "type blah = { foo: String }; entity User { blah: { ?: blah } };"; + let src = "entity E tags Set;"; assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Ok((frag, warnings)) => { assert!(warnings.is_empty()); - let user = frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap(); - assert_matches!(&user.shape, json_schema::EntityAttributes::EntityAttributes(json_schema::EntityAttributesInternal { attrs, .. }) => { - assert_entity_attr_has_type( - attrs.attributes.get("blah").unwrap(), - &json_schema::EntityAttributeTypeInternal::EAMap { - value_type: json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name: "blah".parse().unwrap() }), - } - ); + let entity_type = frag.0.get(&None).unwrap().entity_types.get(&"E".parse().unwrap()).unwrap(); + assert_matches!(&entity_type.tags, Some(json_schema::Type::Type(json_schema::TypeVariant::Set { element })) => { + assert_matches!(&**element, json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name }) => { + assert_eq!(&format!("{type_name}"), "String"); + }); }); }); - } - #[test] - fn nested_ea_map() { - let src = "entity E { tags: { ?: { ?: String } } };"; - assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Err(e) => { - expect_err( - src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("error parsing schema: found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .exactly_one_underline("?: String") - .build(), - ); + let src = "entity E { foo: String } tags { foo: String };"; + assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Ok((frag, warnings)) => { + assert!(warnings.is_empty()); + let entity_type = frag.0.get(&None).unwrap().entity_types.get(&"E".parse().unwrap()).unwrap(); + assert_matches!(&entity_type.tags, Some(json_schema::Type::Type(json_schema::TypeVariant::Record(rty))) => { + assert_matches!(rty.attributes.get("foo"), Some(json_schema::TypeOfAttribute { ty, required }) => { + assert_matches!(ty, json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name }) => { + assert_eq!(&format!("{type_name}"), "String"); + }); + assert_eq!(*required, true); + }); + }); }); - } - #[test] - fn ea_map_and_attribute() { - let src = "entity E { tags: { foo: Long, ?: String } };"; - assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Err(e) => { - expect_err( - src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("error parsing schema: this type contains both concrete attributes and an embedded attribute map (`?:`), which is not allowed") - .exactly_one_underline("?: String") - .build(), - ); + let src = "type T = String; entity E tags T;"; + assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Ok((frag, warnings)) => { + assert!(warnings.is_empty()); + let entity_type = frag.0.get(&None).unwrap().entity_types.get(&"E".parse().unwrap()).unwrap(); + assert_matches!(&entity_type.tags, Some(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name })) => { + assert_eq!(&format!("{type_name}"), "T"); + }); }); - } - #[test] - fn two_ea_maps() { - let src = "entity E { tags: { ?: Long, ?: String } };"; - assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Err(e) => { - expect_err( - src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("error parsing schema: this type contains two or more different embedded attribute map declarations (`?:`), which is not allowed") - .help("try separating this into two different attributes") - .exactly_two_underlines("?: Long,", "?: String") - .build(), - ); + let src = "entity E tags E;"; + assert_matches!(collect_warnings(json_schema::Fragment::from_cedarschema_str(src, Extensions::all_available())), Ok((frag, warnings)) => { + assert!(warnings.is_empty()); + let entity_type = frag.0.get(&None).unwrap().entity_types.get(&"E".parse().unwrap()).unwrap(); + assert_matches!(&entity_type.tags, Some(json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { type_name })) => { + assert_eq!(&format!("{type_name}"), "E"); + }); }); } } diff --git a/cedar-policy-validator/src/cedar_schema/to_json_schema.rs b/cedar-policy-validator/src/cedar_schema/to_json_schema.rs index 808fdb89d1..dd3f6e2f01 100644 --- a/cedar-policy-validator/src/cedar_schema/to_json_schema.rs +++ b/cedar-policy-validator/src/cedar_schema/to_json_schema.rs @@ -23,7 +23,7 @@ use cedar_policy_core::{ extensions::Extensions, parser::{Loc, Node}, }; -use itertools::{Either, Itertools}; +use itertools::Either; use nonempty::NonEmpty; use smol_str::{SmolStr, ToSmolStr}; use std::collections::hash_map::Entry; @@ -33,17 +33,9 @@ use super::{ ActionDecl, AppDecl, AttrDecl, Decl, Declaration, EntityDecl, Namespace, PRAppDecl, Path, QualName, Schema, Type, TypeDecl, BUILTIN_TYPES, PR, }, - err::{ - schema_warnings, EAMapError, EAMapNotAllowedHereError, EAMapNotAllowedHereHelp, - EAMapWithConcreteAttributesError, MultipleEAMapDeclarationsError, SchemaWarning, - ToJsonSchemaError, ToJsonSchemaErrors, - }, -}; -use crate::{ - cedar_schema, - json_schema::{self, is_reserved_schema_keyword, CommonTypeId}, - RawName, + err::{schema_warnings, SchemaWarning, ToJsonSchemaError, ToJsonSchemaErrors}, }; +use crate::{cedar_schema, json_schema, RawName}; impl From for RawName { fn from(p: cedar_schema::Path) -> Self { @@ -101,97 +93,24 @@ fn is_valid_ext_type(ty: &Id, extensions: &Extensions<'_>) -> bool { .any(|ext_ty| ty == ext_ty.basename_as_ref()) } -/// Convert a [`Type`] into the JSON representation of the type. -/// In this function, `EAMap` types are not allowed and will result in errors. -/// For a similar function that accepts `EAMap` types, see -/// [`cedar_type_to_entity_attr_type()`]. -fn cedar_type_to_json_type( - ty: Node, -) -> Result, EAMapNotAllowedHereError> { +/// Convert a `Type` into the JSON representation of the type. +pub fn cedar_type_to_json_type(ty: Node) -> json_schema::Type { match ty.node { - Type::Set(t) => Ok(json_schema::Type::Type(json_schema::TypeVariant::Set { - element: Box::new(cedar_type_to_json_type(*t)?), - })), - Type::Ident(p) => Ok(json_schema::Type::Type( - json_schema::TypeVariant::EntityOrCommon { - type_name: RawName::from(p), - }, - )), - Type::Record(fields) => Ok(json_schema::Type::Type(json_schema::TypeVariant::Record( - json_schema::RecordType { + Type::Set(t) => json_schema::Type::Type(json_schema::TypeVariant::Set { + element: Box::new(cedar_type_to_json_type(*t)), + }), + Type::Ident(p) => json_schema::Type::Type(json_schema::TypeVariant::EntityOrCommon { + type_name: RawName::from(p), + }), + Type::Record(fields) => { + json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes: fields .into_iter() - .map(convert_attr_decl_for_record) - .collect::>()?, + .map(|field| convert_attr_decl(field.node)) + .collect(), additional_attributes: false, - }, - ))), - } -} - -/// Convert a [`Type`] representing an entity attribute type into the JSON -/// representation of the type. Unlike [`cedar_type_to_json_type()`], this -/// function allows (and handles) `EAMap` types as well. -/// -/// This can still return [`EAMapError`] if an `EAMap` is found, e.g., in a set -/// element type, or nested in another `EAMap`. -fn cedar_type_to_entity_attr_type( - ty: Node, -) -> Result, EAMapError> { - match &ty.node { - Type::Record(decls) => { - let (ea_map_decls, non_ea_map_decls): (Vec<(&Loc, &Node)>, Vec<&Node>) = - decls.iter().partition_map(|decl| match &decl.node { - AttrDecl::EAMap { value_ty } => Either::Left((&decl.loc, value_ty)), - _ => Either::Right(decl), - }); - match (ea_map_decls.len(), non_ea_map_decls.len()) { - (0, _) => { - // no `EAMap` decls - Ok(json_schema::EntityAttributeTypeInternal::Type( - cedar_type_to_json_type(ty)?, - )) - } - (1, 0) => { - // exactly one decl, and it's an `EAMap` decl like `?: String`. - // Convert to an `EAMap`. - // PANIC SAFETY: already determined `ea_map_decls.len() == 1` - #[allow(clippy::indexing_slicing)] - let (_, value_type) = ea_map_decls[0]; - Ok(json_schema::EntityAttributeTypeInternal::EAMap { - value_type: cedar_type_to_json_type(value_type.clone())?, - }) - } - (1, 1..) => { - // one `EAMap` decl, but also at least one non-`EAMap` decl. - // This is an error. - // PANIC SAFETY: already determined `ea_map_decls.len() == 1` - #[allow(clippy::indexing_slicing)] - let (source_loc, _) = ea_map_decls[0]; - Err(EAMapWithConcreteAttributesError { - source_loc: source_loc.clone(), - } - .into()) - } - (2.., _) => { - // multiple `EAMap` decls. This is an error. - // PANIC SAFETY: already determined `ea_map_decls.len()` is at least 2 - #[allow(clippy::indexing_slicing)] - let (source_loc_1, _) = ea_map_decls[0]; - // PANIC SAFETY: already determined `ea_map_decls.len()` is at least 2 - #[allow(clippy::indexing_slicing)] - let (source_loc_2, _) = ea_map_decls[1]; - Err(MultipleEAMapDeclarationsError { - source_loc_1: source_loc_1.clone(), - source_loc_2: source_loc_2.clone(), - } - .into()) - } - } + })) } - Type::Set(_) | Type::Ident(_) => Ok(json_schema::EntityAttributeTypeInternal::Type( - cedar_type_to_json_type(ty)?, - )), } } @@ -262,14 +181,12 @@ impl TryFrom for json_schema::NamespaceDefinition { let name_loc = decl.name.loc.clone(); let id = UnreservedId::try_from(decl.name.node) .map_err(|e| ToJsonSchemaError::reserved_name(e.name(), name_loc.clone()))?; - if is_reserved_schema_keyword(&id) { - Err(ToJsonSchemaError::reserved_keyword(id, name_loc)) - } else { - Ok(( - CommonTypeId::unchecked(id), - cedar_type_to_json_type(decl.def).map_err(EAMapError::from)?, - )) - } + let ctid = json_schema::CommonTypeId::new(id).map_err(|e| match e { + json_schema::ReservedCommonTypeBasenameError { id } => { + ToJsonSchemaError::reserved_keyword(id, name_loc) + } + })?; + Ok((ctid, cedar_type_to_json_type(decl.def))) }) .collect::>()?; @@ -297,7 +214,7 @@ fn convert_action_decl( .unwrap_or_else(|| json_schema::ApplySpec { resource_types: vec![], principal_types: vec![], - context: json_schema::RecordOrContextAttributes::default(), + context: json_schema::AttributesOrContext::default(), }); let member_of = parents.map(|parents| parents.into_iter().map(convert_qual_name).collect()); let ty = json_schema::ActionType { @@ -326,7 +243,7 @@ fn convert_app_decls( let (decls, _) = decls.into_inner(); let mut principal_types: Option>> = None; let mut resource_types: Option>> = None; - let mut context: Option>> = None; + let mut context: Option>> = None; for decl in decls { match decl { @@ -344,7 +261,7 @@ fn convert_app_decls( } None => { context = Some(Node::with_source_loc( - convert_context_decl(context_decl).map_err(EAMapError::from)?, + convert_context_decl(context_decl), loc, )); } @@ -430,9 +347,8 @@ fn convert_entity_decl( // First build up the defined entity type let etype = json_schema::EntityType { member_of_types: e.member_of_types.into_iter().map(RawName::from).collect(), - shape: convert_attr_decls_for_entity(e.attrs) - .map(Into::into) - .map_err(ToJsonSchemaError::from)?, + shape: convert_attr_decls(e.attrs), + tags: e.tags.map(|tag_ty| cedar_type_to_json_type(tag_ty)), }; // Then map over all of the bound names @@ -445,27 +361,25 @@ fn convert_entity_decl( ) } -/// Create a [`json_schema::RecordType`] from a series of `AttrDecl`s -/// representing the attributes of an entity -fn convert_attr_decls_for_entity( +/// Create a [`json_schema::AttributesOrContext`] from a series of `AttrDecl`s +fn convert_attr_decls( attrs: impl IntoIterator>, -) -> Result>, EAMapError> { - Ok(json_schema::RecordType { +) -> json_schema::AttributesOrContext { + json_schema::RecordType { attributes: attrs .into_iter() - .map(convert_attr_decl_for_entity) - .collect::>()?, + .map(|attr| convert_attr_decl(attr.node)) + .collect(), additional_attributes: false, - }) + } + .into() } -/// Create a [`json_schema::RecordOrContextAttributes`] from a series of -/// `AttrDecl`s representing the attributes of the context (or a `Path` -/// representing the common-type defining those attributes) +/// Create a context decl fn convert_context_decl( decl: Either>>, -) -> Result, EAMapNotAllowedHereError> { - Ok(json_schema::RecordOrContextAttributes(match decl { +) -> json_schema::AttributesOrContext { + json_schema::AttributesOrContext(match decl { Either::Left(p) => json_schema::Type::CommonTypeRef { type_name: p.into(), }, @@ -473,58 +387,23 @@ fn convert_context_decl( json_schema::Type::Type(json_schema::TypeVariant::Record(json_schema::RecordType { attributes: attrs .into_iter() - .map(convert_attr_decl_for_record) - .collect::>()?, + .map(|attr| convert_attr_decl(attr.node)) + .collect(), additional_attributes: false, })) } - })) + }) } /// Convert an attribute type from an `AttrDecl` -fn convert_attr_decl_for_record( - attr: Node, -) -> Result<(SmolStr, json_schema::RecordAttributeType), EAMapNotAllowedHereError> { - match attr.node { - AttrDecl::Concrete { name, required, ty } => Ok(( - name.node, - json_schema::RecordAttributeType { - ty: cedar_type_to_json_type(ty)?, - required, - }, - )), - AttrDecl::EAMap { .. } => Err(EAMapNotAllowedHereError { - source_loc: attr.loc, - help: None, - }), - } -} - -/// Convert an `AttrDecl` representing an entity attribute type to a pair `(name, ty)`. -/// `attr` is not allowed to be `AttrDecl::EAMap` itself, but in the returned pair -/// `(name, ty)`, `ty` is allowed to be an `EAMap`. -fn convert_attr_decl_for_entity( - attr: Node, -) -> Result<(SmolStr, json_schema::EntityAttributeType), EAMapError> { - match attr.node { - AttrDecl::Concrete { name, required, ty } => Ok(( - name.node, - json_schema::EntityAttributeType { - ty: cedar_type_to_entity_attr_type(ty)?, - required, - }, - )), - AttrDecl::EAMap { .. } => { - // EAMap is not allowed here, as the descriptor of all entity attributes. - // EAMap is only allowed as the top-level type of one of the entity attributes. - // That is, as `ty` in `AttrDecl::Concrete`. - Err(EAMapNotAllowedHereError { - source_loc: attr.loc, - help: Some(EAMapNotAllowedHereHelp::TopLevelEAMap), - } - .into()) - } - } +fn convert_attr_decl(attr: AttrDecl) -> (SmolStr, json_schema::TypeOfAttribute) { + ( + attr.name.node, + json_schema::TypeOfAttribute { + ty: cedar_type_to_json_type(attr.ty), + required: attr.required, + }, + ) } /// Takes a collection of results returning multiple errors diff --git a/cedar-policy-validator/src/coreschema.rs b/cedar-policy-validator/src/coreschema.rs index f75dfdcaf9..0a59b12136 100644 --- a/cedar-policy-validator/src/coreschema.rs +++ b/cedar-policy-validator/src/coreschema.rs @@ -121,21 +121,33 @@ impl entities::EntityTypeDescription for EntityTypeDescription { } fn attr_type(&self, attr: &str) -> Option { - let attr_type: &crate::types::AttributeType = self.validator_type.attr(attr)?; + let attr_type: &crate::types::Type = &self.validator_type.attr(attr)?.attr_type; // This converts a type from a schema into the representation of schema // types used by core. `attr_type` is taken from a `ValidatorEntityType` // which was constructed from a schema. // PANIC SAFETY: see above #[allow(clippy::expect_used)] let core_schema_type: entities::SchemaType = attr_type - .attr_type .clone() .try_into() .expect("failed to convert validator type into Core SchemaType"); - debug_assert!(crate::types::Type::is_consistent_with( - &attr_type.attr_type, - &core_schema_type, - )); + debug_assert!(attr_type.is_consistent_with(&core_schema_type)); + Some(core_schema_type) + } + + #[cfg(feature = "entity-tags")] + fn tag_type(&self) -> Option { + let tag_type: &crate::types::Type = self.validator_type.tag_type()?; + // This converts a type from a schema into the representation of schema + // types used by core. `tag_type` is taken from a `ValidatorEntityType` + // which was constructed from a schema. + // PANIC SAFETY: see above + #[allow(clippy::expect_used)] + let core_schema_type: entities::SchemaType = tag_type + .clone() + .try_into() + .expect("failed to convert validator type into Core SchemaType"); + debug_assert!(tag_type.is_consistent_with(&core_schema_type)); Some(core_schema_type) } diff --git a/cedar-policy-validator/src/diagnostics.rs b/cedar-policy-validator/src/diagnostics.rs index c6160151df..50af83663d 100644 --- a/cedar-policy-validator/src/diagnostics.rs +++ b/cedar-policy-validator/src/diagnostics.rs @@ -22,9 +22,13 @@ use thiserror::Error; use std::collections::BTreeSet; +#[cfg(feature = "entity-tags")] +use cedar_policy_core::ast::Expr; use cedar_policy_core::ast::{EntityType, PolicyID}; use cedar_policy_core::parser::Loc; +#[cfg(feature = "entity-tags")] +use crate::types::EntityLUB; use crate::types::Type; pub mod validation_errors; @@ -124,6 +128,16 @@ pub enum ValidationError { #[error(transparent)] #[diagnostic(transparent)] UnsafeOptionalAttributeAccess(#[from] validation_errors::UnsafeOptionalAttributeAccess), + /// The typechecker could not conclude that an access to a tag was safe. + #[error(transparent)] + #[diagnostic(transparent)] + #[cfg(feature = "entity-tags")] + UnsafeTagAccess(#[from] validation_errors::UnsafeTagAccess), + /// `.getTag()` on an entity type which cannot have tags according to the schema. + #[error(transparent)] + #[diagnostic(transparent)] + #[cfg(feature = "entity-tags")] + NoTagsAllowed(#[from] validation_errors::NoTagsAllowed), /// Undefined extension function. #[error(transparent)] #[diagnostic(transparent)] @@ -152,6 +166,12 @@ pub enum ValidationError { #[error(transparent)] #[diagnostic(transparent)] HierarchyNotRespected(#[from] validation_errors::HierarchyNotRespected), + /// Returned when an internal invariant is violated (should not happen; if + /// this is ever returned, please file an issue) + #[error(transparent)] + #[diagnostic(transparent)] + #[cfg_attr(not(feature = "entity-tags"), allow(dead_code))] + InternalInvariantViolation(#[from] validation_errors::InternalInvariantViolation), } impl ValidationError { @@ -268,6 +288,36 @@ impl ValidationError { .into() } + #[cfg(feature = "entity-tags")] + pub(crate) fn unsafe_tag_access( + source_loc: Option, + policy_id: PolicyID, + entity_ty: Option, + tag: Expr>, + ) -> Self { + validation_errors::UnsafeTagAccess { + source_loc, + policy_id, + entity_ty, + tag, + } + .into() + } + + #[cfg(feature = "entity-tags")] + pub(crate) fn no_tags_allowed( + source_loc: Option, + policy_id: PolicyID, + entity_ty: Option, + ) -> Self { + validation_errors::NoTagsAllowed { + source_loc, + policy_id, + entity_ty, + } + .into() + } + pub(crate) fn undefined_extension( source_loc: Option, policy_id: PolicyID, @@ -283,7 +333,6 @@ impl ValidationError { pub(crate) fn wrong_number_args( source_loc: Option, - policy_id: PolicyID, expected: usize, actual: usize, @@ -328,7 +377,6 @@ impl ValidationError { pub(crate) fn hierarchy_not_respected( source_loc: Option, - policy_id: PolicyID, in_lhs: Option, in_rhs: Option, @@ -341,6 +389,18 @@ impl ValidationError { } .into() } + + #[cfg_attr(not(feature = "entity-tags"), allow(dead_code))] + pub(crate) fn internal_invariant_violation( + source_loc: Option, + policy_id: PolicyID, + ) -> Self { + validation_errors::InternalInvariantViolation { + source_loc, + policy_id, + } + .into() + } } /// Represents the different kinds of validation warnings and information diff --git a/cedar-policy-validator/src/diagnostics/validation_errors.rs b/cedar-policy-validator/src/diagnostics/validation_errors.rs index c1f4e551b7..f3adc63b08 100644 --- a/cedar-policy-validator/src/diagnostics/validation_errors.rs +++ b/cedar-policy-validator/src/diagnostics/validation_errors.rs @@ -254,6 +254,9 @@ pub enum LubContext { /// In the operand of `containsAny` or `containsAll` #[error("elements of both set operands to a `containsAll` or `containsAny` expression")] ContainsAnyAll, + /// While computing the type of a `.getTag()` operation + #[error("tag types for a `.getTag()` operation")] + GetTag, } /// Structure containing details about a missing attribute error. @@ -288,7 +291,7 @@ impl Diagnostic for UnsafeAttributeAccess { /// Structure containing details about an unsafe optional attribute error. #[derive(Error, Debug, Clone, Hash, PartialEq, Eq)] -#[error("unable to guarantee safety of access to optional attribute {attribute_access}")] +#[error("for policy `{policy_id}`, unable to guarantee safety of access to optional attribute {attribute_access}")] pub struct UnsafeOptionalAttributeAccess { /// Source location pub source_loc: Option, @@ -303,12 +306,71 @@ impl Diagnostic for UnsafeOptionalAttributeAccess { fn help<'a>(&'a self) -> Option> { Some(Box::new(format!( - "try testing for the attribute with `{} && ..`", + "try testing for the attribute's presence with `{} && ..`", self.attribute_access.suggested_has_guard() ))) } } +/// Structure containing details about an unsafe tag access error. +#[cfg(feature = "entity-tags")] +#[derive(Error, Debug, Clone, Hash, PartialEq, Eq)] +#[error( + "for policy `{policy_id}`, unable to guarantee safety of access to tag `{tag}`{}", + match .entity_ty.as_ref().and_then(|lub| lub.get_single_entity()) { + Some(ety) => format!(" on entity type `{ety}`"), + None => "".to_string() + } +)] +pub struct UnsafeTagAccess { + /// Source location + pub source_loc: Option, + /// Policy ID where the error occurred + pub policy_id: PolicyID, + /// `EntityLUB` that we tried to access a tag on (or `None` if not an `EntityLUB`, for example, an `AnyEntity`) + pub entity_ty: Option, + /// Tag name which we tried to access. May be a nonconstant `Expr`. + pub tag: Expr>, +} + +#[cfg(feature = "entity-tags")] +impl Diagnostic for UnsafeTagAccess { + impl_diagnostic_from_source_loc_opt_field!(source_loc); + + fn help<'a>(&'a self) -> Option> { + Some(Box::new(format!( + "try testing for the tag's presence with `.hasTag({}) && ..`", + &self.tag + ))) + } +} + +/// Structure containing details about a no-tags-allowed error. +#[cfg(feature = "entity-tags")] +#[derive(Error, Debug, Clone, Hash, PartialEq, Eq)] +#[error( + "for policy `{policy_id}`, `.getTag()` is not allowed on entities of {} because no `tags` were declared on the entity type in the schema", + match .entity_ty.as_ref() { + Some(ty) => format!("type `{ty}`"), + None => "this type".to_string(), + } +)] +pub struct NoTagsAllowed { + /// Source location + pub source_loc: Option, + /// Policy ID where the error occurred + pub policy_id: PolicyID, + /// Entity type which we tried to call `.getTag()` on but which doesn't have any tags allowed in the schema + /// + /// `None` indicates some kind of LUB involving multiple entity types, or `AnyEntity` + pub entity_ty: Option, +} + +#[cfg(feature = "entity-tags")] +impl Diagnostic for NoTagsAllowed { + impl_diagnostic_from_source_loc_opt_field!(source_loc); +} + /// Structure containing details about an undefined function error. #[derive(Error, Debug, Clone, Hash, PartialEq, Eq)] #[error("for policy `{policy_id}`, undefined extension function: {name}")] @@ -421,6 +483,27 @@ impl Diagnostic for NonLitExtConstructor { } } +/// Returned when an internal invariant is violated (should not happen; if +/// this is ever returned, please file an issue) +#[derive(Debug, Clone, Hash, Eq, PartialEq, Error)] +#[error("internal invariant violated")] +pub struct InternalInvariantViolation { + /// Source location + pub source_loc: Option, + /// Policy ID where the error occurred + pub policy_id: PolicyID, +} + +impl Diagnostic for InternalInvariantViolation { + impl_diagnostic_from_source_loc_opt_field!(source_loc); + + fn help<'a>(&'a self) -> Option> { + Some(Box::new( + "please file an issue at including the schema and policy for which you observed the issue" + )) + } +} + /// Contains more detailed information about an attribute access when it occurs /// on an entity type expression or on the `context` variable. Track a `Vec` of /// attributes rather than a single attribute so that on `principal.foo.bar` can @@ -510,10 +593,10 @@ impl Display for AttributeAccess { match self { AttributeAccess::EntityLUB(lub, _) => write!( f, - "`{attrs_str}` for entity type{}", + "`{attrs_str}` on entity type{}", match lub.get_single_entity() { - Some(single) => format!(" {}", single), - _ => format!("s {}", lub.iter().join(", ")), + Some(single) => format!(" `{}`", single), + _ => format!("s {}", lub.iter().map(|ety| format!("`{ety}`")).join(", ")), }, ), AttributeAccess::Context(action, _) => { @@ -604,13 +687,13 @@ mod test_attr_access { .val("User::\"alice\"".parse::().unwrap()), "foo".into(), ); - assert_message_and_help(&e, "`foo` for entity type User", "e has foo"); + assert_message_and_help(&e, "`foo` on entity type `User`", "e has foo"); let e = ExprBuilder::new().get_attr(e, "bar".into()); - assert_message_and_help(&e, "`foo.bar` for entity type User", "e.foo has bar"); + assert_message_and_help(&e, "`foo.bar` on entity type `User`", "e.foo has bar"); let e = ExprBuilder::new().get_attr(e, "baz".into()); assert_message_and_help( &e, - "`foo.bar.baz` for entity type User", + "`foo.bar.baz` on entity type `User`", "e.foo.bar has baz", ); } @@ -623,11 +706,11 @@ mod test_attr_access { .var(Var::Principal), "thing".into(), ); - assert_message_and_help(&e, "`thing` for entity type User", "e has thing"); + assert_message_and_help(&e, "`thing` on entity type `User`", "e has thing"); let e = ExprBuilder::new().get_attr(e, "bar".into()); - assert_message_and_help(&e, "`bar` for entity type Thing", "e has bar"); + assert_message_and_help(&e, "`bar` on entity type `Thing`", "e has bar"); let e = ExprBuilder::new().get_attr(e, "baz".into()); - assert_message_and_help(&e, "`bar.baz` for entity type Thing", "e.bar has baz"); + assert_message_and_help(&e, "`bar.baz` on entity type `Thing`", "e.bar has baz"); } #[test] diff --git a/cedar-policy-validator/src/err.rs b/cedar-policy-validator/src/err.rs index e5a798291b..e7b756aece 100644 --- a/cedar-policy-validator/src/err.rs +++ b/cedar-policy-validator/src/err.rs @@ -621,10 +621,9 @@ pub mod schema_errors { // Action attributes are allowed if `ActionBehavior` is `PermitAttributes` #[error("action declared with attributes: [{}]", .0.iter().join(", "))] ActionAttributes(Vec), - #[error( - "Embedded attribute maps (RFC 68) are not fully implemented in this version of Cedar" - )] - EAMaps, + #[cfg(not(feature = "entity-tags"))] + #[error("entity tags are not supported in this build; to use entity tags, you must enable the `entity-tags` experimental feature")] + EntityTags, } /// This error is thrown when `serde_json` fails to deserialize the JSON diff --git a/cedar-policy-validator/src/json_schema.rs b/cedar-policy-validator/src/json_schema.rs index 9591ec1643..da8e7414f2 100644 --- a/cedar-policy-validator/src/json_schema.rs +++ b/cedar-policy-validator/src/json_schema.rs @@ -20,7 +20,6 @@ use cedar_policy_core::{ ast::{Eid, EntityUID, InternalName, Name, UnreservedId}, entities::CedarValueJson, extensions::Extensions, - jsonvalue::JsonValueWithNoDuplicateKeys, FromNormalizedStr, }; use nonempty::nonempty; @@ -37,6 +36,7 @@ use std::{ marker::PhantomData, str::FromStr, }; +use thiserror::Error; use crate::{ cedar_schema::{ @@ -185,12 +185,52 @@ impl From for UnreservedId { } } +impl AsRef for CommonTypeId { + fn as_ref(&self) -> &UnreservedId { + &self.0 + } +} + impl CommonTypeId { + /// Create a [`CommonTypeId`] from an [`UnreservedId`], failing if it is a reserved basename + pub fn new(id: UnreservedId) -> std::result::Result { + if Self::is_reserved_schema_keyword(&id) { + Err(ReservedCommonTypeBasenameError { id }) + } else { + Ok(Self(id)) + } + } + /// Create a [`CommonTypeId`] based on an [`UnreservedId`] but do not check /// if the latter is valid or not pub fn unchecked(id: UnreservedId) -> Self { Self(id) } + + // Test if this id is a reserved JSON schema keyword. + // Issues: + // https://github.com/cedar-policy/cedar/issues/1070 + // https://github.com/cedar-policy/cedar/issues/1139 + fn is_reserved_schema_keyword(id: &UnreservedId) -> bool { + matches!( + id.as_ref(), + "Bool" | "Boolean" | "Entity" | "Extension" | "Long" | "Record" | "Set" | "String" + ) + } + + /// Make a valid [`CommonTypeId`] from this [`UnreservedId`], modifying the + /// id if needed to avoid reserved basenames + #[cfg(feature = "arbitrary")] + fn make_into_valid_common_type_id(id: UnreservedId) -> Self { + Self::new(id.clone()).unwrap_or_else(|_| { + // PANIC SAFETY: `_Bool`, `_Record`, and etc are valid unreserved names. + #[allow(clippy::unwrap_used)] + let new_id = format!("_{id}").parse().unwrap(); + // PANIC SAFETY: `_Bool`, `_Record`, and etc are valid common type basenames. + #[allow(clippy::unwrap_used)] + Self::new(new_id).unwrap() + }) + } } impl Display for CommonTypeId { @@ -203,14 +243,7 @@ impl Display for CommonTypeId { impl<'a> arbitrary::Arbitrary<'a> for CommonTypeId { fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { let id: UnreservedId = u.arbitrary()?; - if is_reserved_schema_keyword(&id) { - // PANIC SAFETY: `_Bool`, `_Record`, and etc are valid common type names as well as valid unreserved names. - #[allow(clippy::unwrap_used)] - let new_id = format!("_{id}").parse().unwrap(); - Ok(CommonTypeId::unchecked(new_id)) - } else { - Ok(CommonTypeId::unchecked(id)) - } + Ok(CommonTypeId::make_into_valid_common_type_id(id)) } fn size_hint(depth: usize) -> (usize, Option) { @@ -218,17 +251,6 @@ impl<'a> arbitrary::Arbitrary<'a> for CommonTypeId { } } -// Test if this id is a reserved JSON schema keyword. -// Issues: -// https://github.com/cedar-policy/cedar/issues/1070 -// https://github.com/cedar-policy/cedar/issues/1139 -pub(crate) fn is_reserved_schema_keyword(id: &UnreservedId) -> bool { - matches!( - id.as_ref(), - "Bool" | "Boolean" | "Entity" | "Extension" | "Long" | "Record" | "Set" | "String" - ) -} - /// Deserialize a [`CommonTypeId`] impl<'de> Deserialize<'de> for CommonTypeId { fn deserialize(deserializer: D) -> std::result::Result @@ -236,17 +258,19 @@ impl<'de> Deserialize<'de> for CommonTypeId { D: Deserializer<'de>, { UnreservedId::deserialize(deserializer).and_then(|id| { - if is_reserved_schema_keyword(&id) { - Err(serde::de::Error::custom(format!( - "Used reserved schema keyword: {id} " - ))) - } else { - Ok(Self(id)) - } + CommonTypeId::new(id).map_err(|e| serde::de::Error::custom(format!("{e}"))) }) } } +/// Error when a common-type basename is reserved +#[derive(Debug, Error, PartialEq, Clone)] +#[error("this is reserved and cannot be the basename of a common-type declaration: {id}")] +pub struct ReservedCommonTypeBasenameError { + /// `id` that is a reserved common-type basename + pub(crate) id: UnreservedId, +} + /// A single namespace definition from a Fragment. /// This is composed of common types, entity types, and action definitions. /// @@ -368,8 +392,12 @@ pub struct EntityType { pub member_of_types: Vec, /// Description of the attributes for entities of this [`EntityType`]. #[serde(default)] - #[serde(skip_serializing_if = "EntityAttributes::is_empty_record")] - pub shape: EntityAttributes, + #[serde(skip_serializing_if = "AttributesOrContext::is_empty_record")] + pub shape: AttributesOrContext, + /// Tag type for entities of this [`EntityType`]; `None` means entities of this [`EntityType`] do not have tags. + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, } impl EntityType { @@ -385,6 +413,9 @@ impl EntityType { .map(|rname| rname.conditionally_qualify_with(ns, ReferenceType::Entity)) // Only entity, not common, here for now; see #1064 .collect(), shape: self.shape.conditionally_qualify_type_references(ns), + tags: self + .tags + .map(|ty| ty.conditionally_qualify_type_references(ns)), } } } @@ -407,70 +438,74 @@ impl EntityType { .map(|cname| cname.resolve(all_defs)) .collect::>()?, shape: self.shape.fully_qualify_type_references(all_defs)?, + tags: self + .tags + .map(|ty| ty.fully_qualify_type_references(all_defs)) + .transpose()?, }) } } -/// Declaration of record attributes, or of an action context. +/// Declaration of entity or record attributes, or of an action context. /// These share a JSON format. /// /// The parameter `N` is the type of entity type names and common type names in -/// this [`RecordOrContextAttributes`], including recursively. +/// this [`AttributesOrContext`], including recursively. /// See notes on [`Fragment`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(bound(deserialize = "N: Deserialize<'de> + From"))] #[serde(transparent)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub struct RecordOrContextAttributes( +pub struct AttributesOrContext( // We use the usual `Type` deserialization, but it will ultimately need to // be a `Record` or common-type reference which resolves to a `Record`. pub Type, ); -impl RecordOrContextAttributes { - /// Convert the [`RecordOrContextAttributes`] into its [`Type`]. +impl AttributesOrContext { + /// Convert the [`AttributesOrContext`] into its [`Type`]. pub fn into_inner(self) -> Type { self.0 } - /// Is this [`RecordOrContextAttributes`] an empty record? + /// Is this `AttributesOrContext` an empty record? pub fn is_empty_record(&self) -> bool { self.0.is_empty_record() } } -impl Default for RecordOrContextAttributes { +impl Default for AttributesOrContext { fn default() -> Self { Self::from(RecordType::default()) } } -impl Display for RecordOrContextAttributes { +impl Display for AttributesOrContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } -impl From>> for RecordOrContextAttributes { - fn from(rty: RecordType>) -> RecordOrContextAttributes { +impl From> for AttributesOrContext { + fn from(rty: RecordType) -> AttributesOrContext { Self(Type::Type(TypeVariant::Record(rty))) } } -impl RecordOrContextAttributes { +impl AttributesOrContext { /// (Conditionally) prefix unqualified entity and common type references with the namespace they are in pub fn conditionally_qualify_type_references( self, ns: Option<&InternalName>, - ) -> RecordOrContextAttributes { - RecordOrContextAttributes(self.0.conditionally_qualify_type_references(ns)) + ) -> AttributesOrContext { + AttributesOrContext(self.0.conditionally_qualify_type_references(ns)) } } -impl RecordOrContextAttributes { - /// Convert this [`RecordOrContextAttributes`] into a - /// [`RecordOrContextAttributes`] by fully-qualifying all typenames +impl AttributesOrContext { + /// Convert this [`AttributesOrContext`] into an + /// [`AttributesOrContext`] by fully-qualifying all typenames /// that appear anywhere in any definitions. /// /// `all_defs` needs to contain the full set of all fully-qualified typenames @@ -478,266 +513,13 @@ impl RecordOrContextAttributes { pub fn fully_qualify_type_references( self, all_defs: &AllDefs, - ) -> std::result::Result, TypeNotDefinedError> { - Ok(RecordOrContextAttributes( + ) -> std::result::Result, TypeNotDefinedError> { + Ok(AttributesOrContext( self.0.fully_qualify_type_references(all_defs)?, )) } } -/// Declaration of entity attributes -/// -/// The parameter `N` is the type of entity type names and common type names in -/// this [`EntityAttributes`], including recursively. -/// See notes on [`Fragment`]. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(bound(deserialize = "N: Deserialize<'de> + From"))] -#[serde(untagged)] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub enum EntityAttributes { - /// Anything valid as record attributes is valid as entity attributes. - /// Notably, this includes the possibility that we have a single common-type - /// reference, and not actually a record declaration. - RecordAttributes(RecordOrContextAttributes), - /// [`EntityAttributesInternal`] is an analogue of - /// [`RecordOrContextAttributes`] that covers the JSON forms accepted for - /// entity attributes but not record attributes - EntityAttributes(EntityAttributesInternal), -} - -/// Helper struct containing the contents of -/// `EntityAttributes::EntityAttributes`. -/// This doesn't cover all possible legal JSON forms for entity attributes -/// (use [`EntityAttributes`] for that) -- in particular this struct doesn't -/// accept a single common-type reference; it requires a record declaration. -/// But, this struct does cover all legal JSON forms for entity attributes that -/// aren't accepted as legal JSON forms for record attributes. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(bound(deserialize = "N: Deserialize<'de> + From"))] -#[serde(deny_unknown_fields)] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -#[allow(clippy::manual_non_exhaustive)] // a clippy false positive; that's not the reason we're using the `type_placeholder_hack` -pub struct EntityAttributesInternal { - /// a hack for the derived serializer/deserializer. - /// We need to require `"type": "Record"` here (it is required for the - /// corresponding struct [`RecordOrContextAttributes`] by virtue of using - /// the [`Type`] serialization/deserialization). - /// The `serialize_with` and `deserialize_with` accomplish this. - #[serde(rename = "type")] - #[serde(serialize_with = "record_string")] - #[serde(deserialize_with = "require_record_string")] - #[cfg_attr(feature = "wasm", tsify(type = "\"Record\""))] - type_placeholder_hack: (), - /// Entity attribute types, as a [`RecordType`]. These may include `EAMap`s. - #[serde(flatten)] - pub attrs: RecordType>, -} - -fn record_string( - _type_placeholder_hack: &(), - ser: S, -) -> std::result::Result { - ser.serialize_str("Record") -} - -fn require_record_string<'de, D: Deserializer<'de>>(deser: D) -> std::result::Result<(), D::Error> { - /// Simple local visitor struct used only by this function - struct LocalVisitor; - - impl<'de> Visitor<'de> for LocalVisitor { - type Value = (); - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "the string `Record`") - } - fn visit_str(self, s: &str) -> std::result::Result<(), E> { - if s == "Record" { - Ok(()) - } else { - Err(serde::de::Error::invalid_value( - serde::de::Unexpected::Str(s), - &self, - )) - } - } - } - - deser.deserialize_str(LocalVisitor) -} - -impl EntityAttributes { - /// Is this [`EntityAttributes`] an empty record? - pub fn is_empty_record(&self) -> bool { - match self { - Self::RecordAttributes(attrs) => attrs.is_empty_record(), - Self::EntityAttributes(internal) => internal.is_empty_record(), - } - } -} - -impl EntityAttributesInternal { - /// Is this [`EntityAttributesInternal`] an empty record? - pub fn is_empty_record(&self) -> bool { - self.attrs.is_empty_record() - } -} - -impl Default for EntityAttributes { - fn default() -> Self { - Self::RecordAttributes(RecordOrContextAttributes::default()) - } -} - -impl Display for EntityAttributes { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::RecordAttributes(attrs) => attrs.fmt(f), - Self::EntityAttributes(internal) => internal.fmt(f), - } - } -} - -impl Display for EntityAttributesInternal { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.attrs.fmt(f) - } -} - -impl From>> for EntityAttributes { - fn from(rty: RecordType>) -> EntityAttributes { - Self::RecordAttributes(rty.into()) - } -} - -impl From>> for EntityAttributes { - fn from(rty: RecordType>) -> EntityAttributes { - Self::EntityAttributes(EntityAttributesInternal { - type_placeholder_hack: (), - attrs: rty, - }) - } -} - -impl EntityAttributes { - /// (Conditionally) prefix unqualified entity and common type references with the namespace they are in - pub fn conditionally_qualify_type_references( - self, - ns: Option<&InternalName>, - ) -> EntityAttributes { - match self { - Self::RecordAttributes(attrs) => { - EntityAttributes::RecordAttributes(attrs.conditionally_qualify_type_references(ns)) - } - Self::EntityAttributes(internal) => EntityAttributes::EntityAttributes( - internal.conditionally_qualify_type_references(ns), - ), - } - } -} - -impl EntityAttributesInternal { - /// (Conditionally) prefix unqualified entity and common type references with the namespace they are in - pub fn conditionally_qualify_type_references( - self, - ns: Option<&InternalName>, - ) -> EntityAttributesInternal { - EntityAttributesInternal { - type_placeholder_hack: self.type_placeholder_hack, - attrs: self.attrs.conditionally_qualify_type_references(ns), - } - } -} - -impl EntityAttributes { - /// Convert this [`EntityAttributes`] into a - /// [`EntityAttributes`] by fully-qualifying all typenames - /// that appear anywhere in any definitions. - /// - /// `all_defs` needs to contain the full set of all fully-qualified typenames - /// and actions that are defined in the schema (in all schema fragments). - pub fn fully_qualify_type_references( - self, - all_defs: &AllDefs, - ) -> std::result::Result, TypeNotDefinedError> { - match self { - Self::RecordAttributes(attrs) => Ok(EntityAttributes::RecordAttributes( - attrs.fully_qualify_type_references(all_defs)?, - )), - Self::EntityAttributes(internal) => Ok(EntityAttributes::EntityAttributes( - internal.fully_qualify_type_references(all_defs)?, - )), - } - } -} - -impl EntityAttributesInternal { - /// Convert this [`EntityAttributes`] into a - /// [`EntityAttributes`] by fully-qualifying all typenames - /// that appear anywhere in any definitions. - /// - /// `all_defs` needs to contain the full set of all fully-qualified typenames - /// and actions that are defined in the schema (in all schema fragments). - pub fn fully_qualify_type_references( - self, - all_defs: &AllDefs, - ) -> std::result::Result, TypeNotDefinedError> { - Ok(EntityAttributesInternal { - type_placeholder_hack: self.type_placeholder_hack, - attrs: self.attrs.fully_qualify_type_references(all_defs)?, - }) - } -} - -impl<'de, N: Deserialize<'de> + From> Deserialize<'de> for EntityAttributes { - fn deserialize(deserializer: D) -> std::result::Result - where - D: Deserializer<'de>, - { - use serde::de::IntoDeserializer; - // This deserialization attempts to mimic what `serde(untagged)` would - // do, but if the process fails, it gives the error message for the - // `EntityAttributesInternal` case, assuming that that error message is - // usually the most helpful one. - // (The only case it doesn't cover is if you tried, but failed, to use a - // single common-type reference to represent the entity attributes.) - - // Ideally we'd want to "try deserializing" as `RecordOrContextAttributes` - // and if that fails, restore the deserializer state to try - // `EntityAttributesInternal`. - // I'm not sure how `serde(untagged)` does that, and I can't easily - // figure it out from reading the serde source. - // Note that `D` isn't `Clone`. - // As a workaround, we deserialize into `serde_json::Value` first, then - // determine which variant we have, then deserialize the appropriate - // variant. - let value: serde_json::Value = - >::deserialize(deserializer)?.into(); - match value.get("type") { - Some(s) if s != "Record" => { - // This is the only case where we need the `Self::RecordAttributes` variant; - // all other cases can deserialize as `Self::EntityAttributes`, or will have - // an error such that we want the error from the `Self::EntityAttributes` - // deserialization attempt. - let attrs = as Deserialize<'de>>::deserialize( - value.into_deserializer(), - ) - .map_err(|e| serde::de::Error::custom(format!("{e}")))?; - Ok(Self::RecordAttributes(attrs)) - } - _ => { - // In all other cases, we deserialize as `EntityAttributesInternal` or want the - // error message from trying to deserialize as `EntityAttributesInternal`. - let attrs = as Deserialize<'de>>::deserialize( - value.into_deserializer(), - ) - .map_err(|e| serde::de::Error::custom(format!("{e}")))?; - Ok(Self::EntityAttributes(attrs)) - } - } - } -} - /// An [`ActionType`] describes a specific action entity. /// It also describes what principals/resources/contexts are valid for the /// action. @@ -839,8 +621,8 @@ pub struct ApplySpec { pub principal_types: Vec, /// Context type that this action expects #[serde(default)] - #[serde(skip_serializing_if = "RecordOrContextAttributes::is_empty_record")] - pub context: RecordOrContextAttributes, + #[serde(skip_serializing_if = "AttributesOrContext::is_empty_record")] + pub context: AttributesOrContext, } impl ApplySpec { @@ -1236,7 +1018,6 @@ enum TypeFields { Attributes, AdditionalAttributes, Name, - Default, } // This macro is used to avoid duplicating the fields names when calling @@ -1258,9 +1039,6 @@ macro_rules! type_field_name { (Name) => { "name" }; - (Default) => { - "default" - }; } impl TypeFields { @@ -1271,106 +1049,8 @@ impl TypeFields { TypeFields::Attributes => type_field_name!(Attributes), TypeFields::AdditionalAttributes => type_field_name!(AdditionalAttributes), TypeFields::Name => type_field_name!(Name), - TypeFields::Default => type_field_name!(Default), - } - } -} - -/// The fields for a `SchemaType`, with their accompanying data. -/// Used for implementing deserialization. -/// -/// We keep field values wrapped in `Result` here so that we do not report -/// errors due to the contents of a field when the field is not expected/allowed -/// for a particular type variant. -/// We instead report that the field should not exist at all, so that the schema -/// author can delete the field without wasting time fixing errors in the value. -#[derive(Debug)] -struct TypeFieldsWithData { - /// If this is `Some`, the `type` field is present, with the given value - type_name: Option>, - /// If this is `Some`, the `element` field is present, with the given value - element: Option, E>>, - /// If this is `Some`, the `attributes` field is present, with the given value - attributes: Option>, - /// If this is `Some`, the `additional_attributes` field is present, with the given value - additional_attributes: Option>, - /// If this is `Some`, the `name` field is present, with the given value - name: Option>, - /// If this is `Some`, the `default` field is present, with the given value - default: Option, E>>, -} - -/// Manual impl of `Default` (rather than `derive(Default)`) because the derived -/// impl of `Default` requires `N: Default`, but this manual impl works for all `N` -impl Default for TypeFieldsWithData { - fn default() -> Self { - Self { - type_name: None, - element: None, - attributes: None, - additional_attributes: None, - name: None, - default: None, - } - } -} - -/// Helper function to collect the [`TypeFieldsWithData`] from a map type during deserialization. -/// Shared by [`SchemaTypeVisitor`] and [`EntityAttributeTypeInternalVisitor`]. -fn collect_type_fields_data<'de, N: Deserialize<'de> + From, M: MapAccess<'de>>( - mut map: M, -) -> std::result::Result, M::Error> { - use TypeFields::*; - - let mut fields: TypeFieldsWithData = TypeFieldsWithData::default(); - - // Gather all the fields in the object. Any fields that are not one of - // the possible fields for some schema type will have been reported by - // serde already. - while let Some(key) = map.next_key()? { - match key { - Type => { - if fields.type_name.is_some() { - return Err(serde::de::Error::duplicate_field(Type.as_str())); - } - fields.type_name = Some(map.next_value()); - } - Element => { - if fields.element.is_some() { - return Err(serde::de::Error::duplicate_field(Element.as_str())); - } - fields.element = Some(map.next_value()); - } - Attributes => { - if fields.attributes.is_some() { - return Err(serde::de::Error::duplicate_field(Attributes.as_str())); - } - fields.attributes = Some(map.next_value()); - } - AdditionalAttributes => { - if fields.additional_attributes.is_some() { - return Err(serde::de::Error::duplicate_field( - AdditionalAttributes.as_str(), - )); - } - fields.additional_attributes = Some(map.next_value()); - } - Name => { - if fields.name.is_some() { - return Err(serde::de::Error::duplicate_field(Name.as_str())); - } - fields.name = Some(map.next_value()); - } - Default => { - if fields.default.is_some() { - return Err(serde::de::Error::duplicate_field(Default.as_str())); - } - fields.default = Some(map.next_value()); - } } } - - Ok(fields) } /// Used during deserialization to deserialize the attributes type map while @@ -1380,7 +1060,7 @@ fn collect_type_fields_data<'de, N: Deserialize<'de> + From, M: MapAcce #[derive(Debug, Deserialize)] struct AttributesTypeMap( #[serde(with = "serde_with::rust::maps_duplicate_key_is_error")] - BTreeMap>, + BTreeMap>, ); struct TypeVisitor { @@ -1394,18 +1074,64 @@ impl<'de, N: Deserialize<'de> + From> Visitor<'de> for TypeVisitor { formatter.write_str("builtin type or reference to type defined in commonTypes") } - fn visit_map(self, map: M) -> std::result::Result + fn visit_map(self, mut map: M) -> std::result::Result where M: MapAccess<'de>, { - let fields = collect_type_fields_data(map)?; - let eatype = Self::build_schema_type::(fields)?; - // Here, in the deserializer for `Type`, we do not allow EAMap - // types (because `Type` does not allow EAMap types). - match eatype { - EntityAttributeTypeInternal::EAMap { .. } => Err(serde::de::Error::custom("found an embedded attribute map type, but embedded attribute maps are not allowed in this position")), - EntityAttributeTypeInternal::Type(ty) => Ok(ty), + use TypeFields::{AdditionalAttributes, Attributes, Element, Name, Type as TypeField}; + + // We keep field values wrapped in a `Result` initially so that we do + // not report errors due the contents of a field when the field is not + // expected for a particular type variant. We instead report that the + // field so not exist at all, so that the schema author can delete the + // field without wasting time fixing errors in the value. + let mut type_name: Option> = None; + let mut element: Option, M::Error>> = None; + let mut attributes: Option> = None; + let mut additional_attributes: Option> = None; + let mut name: Option> = None; + + // Gather all the fields in the object. Any fields that are not one of + // the possible fields for some schema type will have been reported by + // serde already. + while let Some(key) = map.next_key()? { + match key { + TypeField => { + if type_name.is_some() { + return Err(serde::de::Error::duplicate_field(TypeField.as_str())); + } + type_name = Some(map.next_value()); + } + Element => { + if element.is_some() { + return Err(serde::de::Error::duplicate_field(Element.as_str())); + } + element = Some(map.next_value()); + } + Attributes => { + if attributes.is_some() { + return Err(serde::de::Error::duplicate_field(Attributes.as_str())); + } + attributes = Some(map.next_value()); + } + AdditionalAttributes => { + if additional_attributes.is_some() { + return Err(serde::de::Error::duplicate_field( + AdditionalAttributes.as_str(), + )); + } + additional_attributes = Some(map.next_value()); + } + Name => { + if name.is_some() { + return Err(serde::de::Error::duplicate_field(Name.as_str())); + } + name = Some(map.next_value()); + } + } } + + Self::build_schema_type::(type_name, element, attributes, additional_attributes, name) } } @@ -1414,38 +1140,31 @@ impl<'de, N: Deserialize<'de> + From> TypeVisitor { /// Fields which were not present are `None`. It is an error for a field /// which is not used for a particular type to be `Some` when building that /// type. - /// - /// This method accepts `EAMap` types, and will construct one if it is - /// encountered. - /// Thus it returns [`EntityAttributeTypeInternal`] rather than [`SchemaType`] - /// directly. - /// If `EAMap` types should not be accepted in this position, it is the - /// caller's responsibility to check that the [`EntityAttributeTypeInternal`] - /// is an acceptable variant. fn build_schema_type( - fields: TypeFieldsWithData, - ) -> std::result::Result, M::Error> + type_name: Option>, + element: Option, M::Error>>, + attributes: Option>, + additional_attributes: Option>, + name: Option>, + ) -> std::result::Result, M::Error> where M: MapAccess<'de>, { - use TypeFields::{ - AdditionalAttributes, Attributes, Default, Element, Name, Type as TypeField, - }; + use TypeFields::{AdditionalAttributes, Attributes, Element, Name, Type as TypeField}; // Fields that remain to be parsed let mut remaining_fields = [ - (TypeField, fields.type_name.is_some()), - (Element, fields.element.is_some()), - (Attributes, fields.attributes.is_some()), - (AdditionalAttributes, fields.additional_attributes.is_some()), - (Name, fields.name.is_some()), - (Default, fields.default.is_some()), + (TypeField, type_name.is_some()), + (Element, element.is_some()), + (Attributes, attributes.is_some()), + (AdditionalAttributes, additional_attributes.is_some()), + (Name, name.is_some()), ] .into_iter() .filter(|(_, present)| *present) .map(|(field, _)| field) .collect::>(); - match fields.type_name.transpose()?.as_ref() { + match type_name.transpose()?.as_ref() { Some(s) => { // We've concluded that type exists remaining_fields.remove(&TypeField); @@ -1462,42 +1181,31 @@ impl<'de, N: Deserialize<'de> + From> TypeVisitor { Ok(()) }; let error_if_any_fields = || -> std::result::Result<(), M::Error> { - error_if_fields( - &[Element, Attributes, AdditionalAttributes, Name, Default], - &[], - ) + error_if_fields(&[Element, Attributes, AdditionalAttributes, Name], &[]) }; match s.as_str() { "String" => { error_if_any_fields()?; - Ok(EntityAttributeTypeInternal::Type(Type::Type( - TypeVariant::String, - ))) + Ok(Type::Type(TypeVariant::String)) } "Long" => { error_if_any_fields()?; - Ok(EntityAttributeTypeInternal::Type(Type::Type( - TypeVariant::Long, - ))) + Ok(Type::Type(TypeVariant::Long)) } "Boolean" => { error_if_any_fields()?; - Ok(EntityAttributeTypeInternal::Type(Type::Type( - TypeVariant::Boolean, - ))) + Ok(Type::Type(TypeVariant::Boolean)) } "Set" => { error_if_fields( - &[Attributes, AdditionalAttributes, Default, Name], + &[Attributes, AdditionalAttributes, Name], &[type_field_name!(Element)], )?; - match fields.element { - Some(element) => Ok(EntityAttributeTypeInternal::Type(Type::Type( - TypeVariant::Set { - element: Box::new(element?), - }, - ))), + match element { + Some(element) => Ok(Type::Type(TypeVariant::Set { + element: Box::new(element?), + })), None => Err(serde::de::Error::missing_field(Element.as_str())), } } @@ -1507,121 +1215,99 @@ impl<'de, N: Deserialize<'de> + From> TypeVisitor { &[ type_field_name!(Attributes), type_field_name!(AdditionalAttributes), - type_field_name!(Default), ], )?; - if let Some(attributes) = fields.attributes { - if fields.default.is_some() { - return Err(serde::de::Error::custom("fields `default` and `attributes` cannot exist on the same record type")); - } - let additional_attributes = fields - .additional_attributes - .unwrap_or(Ok(partial_schema_default())); - Ok(EntityAttributeTypeInternal::Type(Type::Type( - TypeVariant::Record(RecordType { - attributes: attributes? - .0 - .into_iter() - .map(|(k, RecordAttributeType { ty, required })| { - ( - k, - RecordAttributeType { - ty: ty.into_n(), - required, - }, - ) - }) - .collect(), - additional_attributes: additional_attributes?, - }), - ))) - } else if let Some(default) = fields.default { - if fields.attributes.is_some() { - return Err(serde::de::Error::custom("fields `default` and `attributes` cannot exist on the same record type")); - } else if fields.additional_attributes.is_some() { - return Err(serde::de::Error::custom("fields `default` and `additionalAttributes` cannot exist on the same record type")); - } - Ok(EntityAttributeTypeInternal::EAMap { - value_type: default?, - }) + if let Some(attributes) = attributes { + let additional_attributes = + additional_attributes.unwrap_or(Ok(partial_schema_default())); + Ok(Type::Type(TypeVariant::Record(RecordType { + attributes: attributes? + .0 + .into_iter() + .map(|(k, TypeOfAttribute { ty, required })| { + ( + k, + TypeOfAttribute { + ty: ty.into_n(), + required, + }, + ) + }) + .collect(), + additional_attributes: additional_attributes?, + }))) } else { Err(serde::de::Error::missing_field(Attributes.as_str())) } } "Entity" => { error_if_fields( - &[Element, Attributes, AdditionalAttributes, Default], + &[Element, Attributes, AdditionalAttributes], &[type_field_name!(Name)], )?; - match fields.name { + match name { Some(name) => { let name = name?; - Ok(EntityAttributeTypeInternal::Type(Type::Type( - TypeVariant::Entity { - name: RawName::from_normalized_str(&name) - .map_err(|err| { - serde::de::Error::custom(format!( - "invalid entity type `{name}`: {err}" - )) - })? - .into(), - }, - ))) + Ok(Type::Type(TypeVariant::Entity { + name: RawName::from_normalized_str(&name) + .map_err(|err| { + serde::de::Error::custom(format!( + "invalid entity type `{name}`: {err}" + )) + })? + .into(), + })) } None => Err(serde::de::Error::missing_field(Name.as_str())), } } "EntityOrCommon" => { error_if_fields( - &[Element, Attributes, AdditionalAttributes, Default], + &[Element, Attributes, AdditionalAttributes], &[type_field_name!(Name)], )?; - match fields.name { + match name { Some(name) => { let name = name?; - Ok(EntityAttributeTypeInternal::Type(Type::Type( - TypeVariant::EntityOrCommon { - type_name: RawName::from_normalized_str(&name) - .map_err(|err| { - serde::de::Error::custom(format!( - "invalid entity or common type `{name}`: {err}" - )) - })? - .into(), - }, - ))) + Ok(Type::Type(TypeVariant::EntityOrCommon { + type_name: RawName::from_normalized_str(&name) + .map_err(|err| { + serde::de::Error::custom(format!( + "invalid entity or common type `{name}`: {err}" + )) + })? + .into(), + })) } None => Err(serde::de::Error::missing_field(Name.as_str())), } } "Extension" => { error_if_fields( - &[Element, Attributes, AdditionalAttributes, Default], + &[Element, Attributes, AdditionalAttributes], &[type_field_name!(Name)], )?; - match fields.name { + match name { Some(name) => { let name = name?; - Ok(EntityAttributeTypeInternal::Type(Type::Type( - TypeVariant::Extension { - name: UnreservedId::from_normalized_str(&name).map_err( - |err| { - serde::de::Error::custom(format!( - "invalid extension type `{name}`: {err}" - )) - }, - )?, - }, - ))) + Ok(Type::Type(TypeVariant::Extension { + name: UnreservedId::from_normalized_str(&name).map_err( + |err| { + serde::de::Error::custom(format!( + "invalid extension type `{name}`: {err}" + )) + }, + )?, + })) } None => Err(serde::de::Error::missing_field(Name.as_str())), } } type_name => { error_if_any_fields()?; - Ok(EntityAttributeTypeInternal::Type(Type::CommonTypeRef { + Ok(Type::CommonTypeRef { type_name: N::from(RawName::from_normalized_str(type_name).map_err( |err| { serde::de::Error::custom(format!( @@ -1629,7 +1315,7 @@ impl<'de, N: Deserialize<'de> + From> TypeVisitor { )) }, )?), - })) + }) } } } @@ -1646,29 +1332,24 @@ impl From> for Type { /// Represents the type-level information about a record type. /// -/// `V` is the type of attribute values in the record. -/// For instance, when `V` is [`RecordAttributeType`], this [`RecordType`] -/// represents the associated information for [`TypeVariant::Record`]. -/// Entity attribute values are also allowed to be `EAMap`s, so in that -/// case `V` is [`EntityAttributeType`]. +/// The parameter `N` is the type of entity type names and common type names in +/// this [`RecordType`], including recursively. +/// See notes on [`Fragment`]. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(bound(deserialize = "N: Deserialize<'de> + From"))] #[serde(rename_all = "camelCase")] -#[serde(deny_unknown_fields)] -#[serde(bound(deserialize = "V: Deserialize<'de>"))] -#[serde(bound(serialize = "V: Serialize"))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub struct RecordType { +pub struct RecordType { /// Attribute names and types for the record - #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")] - pub attributes: BTreeMap, + pub attributes: BTreeMap>, /// Whether "additional attributes" are possible on this record #[serde(default = "partial_schema_default")] #[serde(skip_serializing_if = "is_partial_schema_default")] pub additional_attributes: bool, } -impl Default for RecordType { +impl Default for RecordType { fn default() -> Self { Self { attributes: BTreeMap::new(), @@ -1677,36 +1358,19 @@ impl Default for RecordType { } } -impl RecordType { +impl RecordType { /// Is this [`RecordType`] an empty record? pub fn is_empty_record(&self) -> bool { self.additional_attributes == partial_schema_default() && self.attributes.is_empty() } } -impl RecordType> { - /// (Conditionally) prefix unqualified entity and common type references with the namespace they are in - pub fn conditionally_qualify_type_references( - self, - ns: Option<&InternalName>, - ) -> RecordType> { - RecordType { - attributes: self - .attributes - .into_iter() - .map(|(k, v)| (k, v.conditionally_qualify_type_references(ns))) - .collect(), - additional_attributes: self.additional_attributes, - } - } -} - -impl RecordType> { +impl RecordType { /// (Conditionally) prefix unqualified entity and common type references with the namespace they are in pub fn conditionally_qualify_type_references( self, ns: Option<&InternalName>, - ) -> RecordType> { + ) -> RecordType { RecordType { attributes: self .attributes @@ -1718,41 +1382,17 @@ impl RecordType> { } } -impl RecordType> { - /// Convert this [`RecordType>`] into a - /// [`RecordType>`] by fully-qualifying - /// all typenames that appear anywhere in any definitions. - /// - /// `all_defs` needs to contain the full set of all fully-qualified typenames - /// and actions that are defined in the schema (in all schema fragments). - pub fn fully_qualify_type_references( - self, - all_defs: &AllDefs, - ) -> std::result::Result>, TypeNotDefinedError> - { - Ok(RecordType { - attributes: self - .attributes - .into_iter() - .map(|(k, v)| Ok((k, v.fully_qualify_type_references(all_defs)?))) - .collect::>()?, - additional_attributes: self.additional_attributes, - }) - } -} - -impl RecordType> { - /// Convert this [`RecordType>`] into a - /// [`RecordType>`] by fully-qualifying - /// all typenames that appear anywhere in any definitions. +impl RecordType { + /// Convert this [`RecordType`] into a + /// [`RecordType`] by fully-qualifying all typenames that + /// appear anywhere in any definitions. /// /// `all_defs` needs to contain the full set of all fully-qualified typenames /// and actions that are defined in the schema (in all schema fragments). pub fn fully_qualify_type_references( self, all_defs: &AllDefs, - ) -> std::result::Result>, TypeNotDefinedError> - { + ) -> std::result::Result, TypeNotDefinedError> { Ok(RecordType { attributes: self .attributes @@ -1789,7 +1429,7 @@ pub enum TypeVariant { element: Box>, }, /// Record - Record(RecordType>), + Record(RecordType), /// Entity Entity { /// Name of the entity type. @@ -1855,10 +1495,10 @@ impl TypeVariant { additional_attributes, }) => TypeVariant::Record(RecordType { attributes: BTreeMap::from_iter(attributes.into_iter().map( - |(attr, RecordAttributeType { ty, required })| { + |(attr, TypeOfAttribute { ty, required })| { ( attr, - RecordAttributeType { + TypeOfAttribute { ty: ty.conditionally_qualify_type_references(ns), required, }, @@ -1928,10 +1568,10 @@ impl TypeVariant { }) => Ok(TypeVariant::Record(RecordType { attributes: attributes .into_iter() - .map(|(attr, RecordAttributeType { ty, required })| { + .map(|(attr, TypeOfAttribute { ty, required })| { Ok(( attr, - RecordAttributeType { + TypeOfAttribute { ty: ty.fully_qualify_type_references(all_defs)?, required, }, @@ -1997,225 +1637,39 @@ impl<'a> arbitrary::Arbitrary<'a> for Type { } } -/// Describes the underlying type of an entity attribute (not including the -/// required/optional flag). +/// Used to describe the type of a record or entity attribute. It contains a the +/// type of the attribute and whether the attribute is required. The type is +/// flattened for serialization, so, in JSON format, this appears as a regular +/// type with one extra property `required`. /// -/// The allowed types for an entity attribute are different from the allowed -/// types for a record attribute. See -/// [RFC 68](https://github.com/cedar-policy/rfcs/blob/main/text/0068-entity-tags.md). -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub enum EntityAttributeTypeInternal { - /// A normal type. Attributes can be `String`, an entity type, a common type, a record type, etc - Type(Type), - /// An embedded attribute map (RFC 68) - /// - /// That is, a map from String to the given value type. - EAMap { - /// The `EAMap` is a map from String to this value type. - /// - /// Note that this value type may not itself be (or contain) `EAMap`s. - value_type: Type, - }, +/// The parameter `N` is the type of entity type names and common type names in +/// this [`TypeOfAttribute`], including recursively. +/// See notes on [`Fragment`]. +/// +/// Note that we can't add `#[serde(deny_unknown_fields)]` here because we are +/// using `#[serde(tag = "type")]` in [`Type`] which is flattened here. +/// The way `serde(flatten)` is implemented means it may be possible to access +/// fields incorrectly if a struct contains two structs that are flattened +/// (``). This shouldn't apply to +/// us as we're using `flatten` only once +/// (``). This should be ok because +/// unknown fields for [`TypeOfAttribute`] should be passed to [`Type`] where +/// they will be denied (``). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, PartialOrd, Ord)] +#[serde(bound(deserialize = "N: Deserialize<'de> + From"))] +pub struct TypeOfAttribute { + /// Underlying type of the attribute + #[serde(flatten)] + pub ty: Type, + /// Whether the attribute is required + #[serde(default = "record_attribute_required_default")] + #[serde(skip_serializing_if = "is_record_attribute_required_default")] + pub required: bool, } -impl EntityAttributeTypeInternal { - /// (Conditionally) prefix unqualified entity and common type references with the namespace they are in - pub fn conditionally_qualify_type_references( - self, - ns: Option<&InternalName>, - ) -> EntityAttributeTypeInternal { - match self { - Self::Type(ty) => { - EntityAttributeTypeInternal::Type(ty.conditionally_qualify_type_references(ns)) - } - Self::EAMap { value_type } => EntityAttributeTypeInternal::EAMap { - value_type: value_type.conditionally_qualify_type_references(ns), - }, - } - } -} - -impl EntityAttributeTypeInternal { - /// Convert this [`EntityAttributeTypeInternal`] into a - /// [`EntityAttributeTypeInternal`] by fully-qualifying all - /// typenames that appear anywhere in any definitions. - /// - /// `all_defs` needs to contain the full set of all fully-qualified typenames - /// and actions that are defined in the schema (in all schema fragments). - pub fn fully_qualify_type_references( - self, - all_defs: &AllDefs, - ) -> std::result::Result, TypeNotDefinedError> { - match self { - Self::Type(ty) => Ok(EntityAttributeTypeInternal::Type( - ty.fully_qualify_type_references(all_defs)?, - )), - Self::EAMap { value_type } => Ok(EntityAttributeTypeInternal::EAMap { - value_type: value_type.fully_qualify_type_references(all_defs)?, - }), - } - } -} - -impl Serialize for EntityAttributeTypeInternal { - fn serialize(&self, serializer: S) -> std::result::Result - where - S: Serializer, - { - match self { - Self::Type(ty) => ty.serialize(serializer), - Self::EAMap { value_type } => { - serde_json::json!({"type": "Record", "default": value_type}).serialize(serializer) - } - } - } -} - -struct EntityAttributeTypeInternalVisitor { - _phantom: PhantomData, -} - -impl<'de, N: Deserialize<'de> + From> Deserialize<'de> for EntityAttributeTypeInternal { - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - deserializer.deserialize_any(EntityAttributeTypeInternalVisitor { - _phantom: PhantomData, - }) - } -} - -impl<'de, N: Deserialize<'de> + From> Visitor<'de> - for EntityAttributeTypeInternalVisitor -{ - type Value = EntityAttributeTypeInternal; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("any valid type, including an embedded attribute map type") - } - - fn visit_map(self, map: M) -> std::result::Result - where - M: MapAccess<'de>, - { - let fields = collect_type_fields_data(map)?; - TypeVisitor::build_schema_type::(fields) - } -} - -/// Describes the type of an entity attribute. It contains the type of the -/// attribute and whether the attribute is required. The type is flattened for -/// serialization, so, in JSON format, this appears as a regular type with one -/// extra property `required`. -/// -/// The parameter `N` is the type of entity type names and common type names in -/// this [`EntityAttributeType`], including recursively. -/// See notes on [`Fragment`]. -/// -/// Note that we can't add `#[serde(deny_unknown_fields)]` here because we are -/// using `#[serde(tag = "type")]` in [`Type`] which is (eventually) flattened -/// here. -/// The way `serde(flatten)` is implemented means it may be possible to access -/// fields incorrectly if a struct contains two structs that are flattened -/// (``). This shouldn't apply to -/// us as we're using `flatten` only once -/// (``). This should be ok because -/// unknown fields for [`EntityAttributeType`] should be passed to [`Type`] -/// where they will be denied -/// (``). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, PartialOrd, Ord)] -#[serde(bound(deserialize = "N: Deserialize<'de> + From"))] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub struct EntityAttributeType { - /// Underlying type of the attribute - #[serde(flatten)] - // without this explicit `tsify` type, as of this writing, tsify produces a declaration - // `export interface EntityAttributeType extends EntityAttributeTypeInternal { required?: boolean; }` - // which `tsc` fails with `error TS2312: An interface can only extend an object - // type or intersection of object types with statically known members.` - #[cfg_attr(feature = "wasm", tsify(type = "EntityAttributeTypeInternal"))] - pub ty: EntityAttributeTypeInternal, - /// Whether the attribute is required - #[serde(default = "record_attribute_required_default")] - #[serde(skip_serializing_if = "is_record_attribute_required_default")] - pub required: bool, -} - -impl EntityAttributeType { - /// (Conditionally) prefix unqualified entity and common type references with the namespace they are in - pub fn conditionally_qualify_type_references( - self, - ns: Option<&InternalName>, - ) -> EntityAttributeType { - EntityAttributeType { - ty: self.ty.conditionally_qualify_type_references(ns), - required: self.required, - } - } -} - -impl EntityAttributeType { - /// Convert this [`EntityAttributeType`] into a - /// [`EntityAttributeType`] by fully-qualifying - /// all typenames that appear anywhere in any definitions. - /// - /// `all_defs` needs to contain the full set of all fully-qualified typenames - /// and actions that are defined in the schema (in all schema fragments). - pub fn fully_qualify_type_references( - self, - all_defs: &AllDefs, - ) -> std::result::Result, TypeNotDefinedError> { - Ok(EntityAttributeType { - ty: self.ty.fully_qualify_type_references(all_defs)?, - required: self.required, - }) - } -} - -/// Describes the type of a record attribute. It contains the type of the -/// attribute and whether the attribute is required. The type is flattened for -/// serialization, so, in JSON format, this appears as a regular type with one -/// extra property `required`. -/// -/// The parameter `N` is the type of entity type names and common type names in -/// this [`RecordAttributeType`], including recursively. -/// See notes on [`Fragment`]. -/// -/// Note that we can't add `#[serde(deny_unknown_fields)]` here because we are -/// using `#[serde(tag = "type")]` in [`Type`] which is flattened here. -/// The way `serde(flatten)` is implemented means it may be possible to access -/// fields incorrectly if a struct contains two structs that are flattened -/// (``). This shouldn't apply to -/// us as we're using `flatten` only once -/// (``). This should be ok because -/// unknown fields for [`RecordAttributeType`] should be passed to [`Type`] where -/// they will be denied (``). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, PartialOrd, Ord)] -#[serde(bound(deserialize = "N: Deserialize<'de> + From"))] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub struct RecordAttributeType { - /// Underlying type of the attribute - #[serde(flatten)] - // without this explicit `tsify` type, as of this writing, tsify produces a declaration - // `export interface RecordAttributeType extends Type { required?: boolean; }` - // which `tsc` fails with `error TS2312: An interface can only extend an object - // type or intersection of object types with statically known members.` - #[cfg_attr(feature = "wasm", tsify(type = "Type"))] - pub ty: Type, - /// Whether the attribute is required - #[serde(default = "record_attribute_required_default")] - #[serde(skip_serializing_if = "is_record_attribute_required_default")] - pub required: bool, -} - -impl RecordAttributeType { - fn into_n>(self) -> RecordAttributeType { - RecordAttributeType { +impl TypeOfAttribute { + fn into_n>(self) -> TypeOfAttribute { + TypeOfAttribute { ty: self.ty.into_n(), required: self.required, } @@ -2225,26 +1679,26 @@ impl RecordAttributeType { pub fn conditionally_qualify_type_references( self, ns: Option<&InternalName>, - ) -> RecordAttributeType { - RecordAttributeType { + ) -> TypeOfAttribute { + TypeOfAttribute { ty: self.ty.conditionally_qualify_type_references(ns), required: self.required, } } } -impl RecordAttributeType { - /// Convert this [`RecordAttributeType`] into a - /// [`RecordAttributeType`] by fully-qualifying - /// all typenames that appear anywhere in any definitions. +impl TypeOfAttribute { + /// Convert this [`TypeOfAttribute`] into a + /// [`TypeOfAttribute`] by fully-qualifying all typenames that + /// appear anywhere in any definitions. /// /// `all_defs` needs to contain the full set of all fully-qualified typenames /// and actions that are defined in the schema (in all schema fragments). pub fn fully_qualify_type_references( self, all_defs: &AllDefs, - ) -> std::result::Result, TypeNotDefinedError> { - Ok(RecordAttributeType { + ) -> std::result::Result, TypeNotDefinedError> { + Ok(TypeOfAttribute { ty: self.ty.fully_qualify_type_references(all_defs)?, required: self.required, }) @@ -2252,7 +1706,7 @@ impl RecordAttributeType { } #[cfg(feature = "arbitrary")] -impl<'a> arbitrary::Arbitrary<'a> for RecordAttributeType { +impl<'a> arbitrary::Arbitrary<'a> for TypeOfAttribute { fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { Ok(Self { ty: u.arbitrary()?, @@ -2307,12 +1761,10 @@ mod test { assert_eq!(et.member_of_types, vec!["UserGroup".parse().unwrap()]); assert_eq!( et.shape, - EntityAttributes::RecordAttributes(RecordOrContextAttributes(Type::Type( - TypeVariant::Record(RecordType { - attributes: BTreeMap::new(), - additional_attributes: false - }) - ))), + AttributesOrContext(Type::Type(TypeVariant::Record(RecordType { + attributes: BTreeMap::new(), + additional_attributes: false + }))), ); } @@ -2325,12 +1777,10 @@ mod test { assert_eq!(et.member_of_types.len(), 0); assert_eq!( et.shape, - EntityAttributes::RecordAttributes(RecordOrContextAttributes(Type::Type( - TypeVariant::Record(RecordType { - attributes: BTreeMap::new(), - additional_attributes: false - }) - ))), + AttributesOrContext(Type::Type(TypeVariant::Record(RecordType { + attributes: BTreeMap::new(), + additional_attributes: false + }))), ); } @@ -2349,7 +1799,7 @@ mod test { let spec = ApplySpec { resource_types: vec!["Album".parse().unwrap()], principal_types: vec!["User".parse().unwrap()], - context: RecordOrContextAttributes::default(), + context: AttributesOrContext::default(), }; assert_eq!(at.applies_to, Some(spec)); assert_eq!( @@ -2440,7 +1890,7 @@ mod test { "actions": {} } }"#; - let schema = Fragment::from_json_str(src).expect("Parse Error"); + let schema: Fragment = serde_json::from_str(src).expect("Parse Error"); let (namespace, _descriptor) = schema.0.into_iter().next().unwrap(); assert_eq!(namespace, Some("foo::foo::bar::baz".parse().unwrap())); } @@ -2994,434 +2444,144 @@ mod strengthened_types { } } -/// Tests involving `EAMap`s (RFC 68) +/// Tests involving entity tags (RFC 82) #[cfg(test)] -mod ea_maps { +mod entity_tags { use super::*; - use crate::cedar_schema::test::assert_entity_attr_has_type; use cedar_policy_core::test_utils::{expect_err, ExpectedErrorMessageBuilder}; use cool_asserts::assert_matches; + use serde_json::json; + /// This schema taken directly from the RFC 82 text #[test] - fn entity_attribute() { - // This schema taken directly from the RFC 68 text - let src = serde_json::json!({ - "": { - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "attributes": { - "jobLevel": { - "type": "Long", - }, - "authTags": { - "type": "Record", - "default": { - "type": "Set", - "element": { "type": "String" }, - } - } - } + fn basic() { + let json = json!({"": { + "entityTypes": { + "User" : { + "shape" : { + "type" : "Record", + "attributes" : { + "jobLevel" : { + "type" : "Long" + }, } }, - "Document": { - "shape": { - "type": "Record", - "attributes": { - "owner": { - "type": "Entity", - "name": "User", - }, - "policyTags": { - "type": "Record", - "default": { - "type": "Set", - "element": { "type": "String" }, - } - } - } - } + "tags" : { + "type" : "Set", + "element": { "type": "String" } } }, - "actions": {} - } - }); - assert_matches!(Fragment::from_json_value(src), Ok(frag) => { + "Document" : { + "shape" : { + "type" : "Record", + "attributes" : { + "owner" : { + "type" : "Entity", + "name" : "User" + }, + } + }, + "tags" : { + "type" : "Set", + "element": { "type": "String" } + } + } + }, + "actions": {} + }}); + assert_matches!(Fragment::from_json_value(json), Ok(frag) => { let user = frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap(); - assert_matches!(&user.shape, EntityAttributes::EntityAttributes(EntityAttributesInternal { attrs, .. }) => { - assert_entity_attr_has_type( - attrs.attributes.get("jobLevel").unwrap(), - &EntityAttributeTypeInternal::Type(Type::Type(TypeVariant::Long)), - ); - assert_entity_attr_has_type( - attrs.attributes.get("authTags").unwrap(), - &EntityAttributeTypeInternal::EAMap { value_type: Type::Type(TypeVariant::Set { element: Box::new(Type::Type(TypeVariant::String)) }) }, - ); + assert_matches!(&user.tags, Some(Type::Type(TypeVariant::Set { element })) => { + assert_matches!(&**element, Type::Type(TypeVariant::String)); // TODO: why is this `TypeVariant::String` in this case but `EntityOrCommon { "String" }` in all the other cases in this test? Do we accept common types as the element type for sets? }); let doc = frag.0.get(&None).unwrap().entity_types.get(&"Document".parse().unwrap()).unwrap(); - assert_matches!(&doc.shape, EntityAttributes::EntityAttributes(EntityAttributesInternal { attrs, .. }) => { - assert_entity_attr_has_type( - attrs.attributes.get("owner").unwrap(), - &EntityAttributeTypeInternal::Type(Type::Type(TypeVariant::Entity { name: "User".parse().unwrap() })), - ); - assert_entity_attr_has_type( - attrs.attributes.get("policyTags").unwrap(), - &EntityAttributeTypeInternal::EAMap { value_type: Type::Type(TypeVariant::Set { element: Box::new(Type::Type(TypeVariant::String)) }) }, - ); + assert_matches!(&doc.tags, Some(Type::Type(TypeVariant::Set { element })) => { + assert_matches!(&**element, Type::Type(TypeVariant::String)); // TODO: why is this `TypeVariant::String` in this case but `EntityOrCommon { "String" }` in all the other cases in this test? Do we accept common types as the element type for sets? }); - }); - } - - #[test] - fn record_attribute_inside_entity_attribute() { - let src = serde_json::json!({ - "": { - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "attributes": { - "userDetails": { - "type": "Record", - "attributes": { - "tags": { - "type": "Record", - "default": { - "type": "String" - } - }, - }, - } - } - } - } - }, - "actions": {} - } - }); - assert_matches!(Fragment::from_json_value(src.clone()), Err(e) => { - expect_err( - &src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .build(), - ); - }); - } - - #[test] - fn context_attribute() { - let src = serde_json::json!({ - "": { - "actions": { - "read": { - "appliesTo": { - "principalTypes": ["E"], - "resourceTypes": ["E"], - "context": { - "type": "Record", - "attributes": { - "operationDetails": { - "type": "Record", - "default": { - "type": "String" - }, - } - } - } - } - } - }, - "entityTypes": {} - } - }); - assert_matches!(Fragment::from_json_value(src.clone()), Err(e) => { - expect_err( - &src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .build(), - ); - }); - } - - #[test] - fn toplevel_entity() { - let src = serde_json::json!({ - "": { - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "default": { - "type": "String" - } - } - } - }, - "actions": {} - } - }); - assert_matches!(Fragment::from_json_value(src.clone()), Err(e) => { - expect_err( - &src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("missing field `attributes`").build(), - ); - }); - } - - #[test] - fn toplevel_context() { - let src = serde_json::json!({ - "": { - "actions": { - "read": { - "appliesTo": { - "principalTypes": ["E"], - "resourceTypes": ["E"], - "context": { - "type": "Record", - "default": { - "type": "String" - }, - } - } - } - }, - "entityTypes": {} - } - }); - assert_matches!(Fragment::from_json_value(src.clone()), Err(e) => { - expect_err( - &src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .build(), - ); - }); + }) } + /// In this schema, the tag type is a common type #[test] - fn common_type() { - let src = serde_json::json!({ - "": { - "commonTypes": { - "blah": { - "type": "Record", - "default": { - "type": "String" - } - }, - }, - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "attributes": { - "blah": { - "type": "blah" - }, - } + fn tag_type_is_common_type() { + let json = json!({"": { + "commonTypes": { + "T": { "type": "String" }, + }, + "entityTypes": { + "User" : { + "shape" : { + "type" : "Record", + "attributes" : { + "jobLevel" : { + "type" : "Long" + }, } }, + "tags" : { "type" : "T" }, }, - "actions": {} - } - }); - assert_matches!(Fragment::from_json_value(src.clone()), Err(e) => { - expect_err( - &src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .build(), - ); - }); + }, + "actions": {} + }}); + assert_matches!(Fragment::from_json_value(json), Ok(frag) => { + let user = frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap(); + assert_matches!(&user.tags, Some(Type::CommonTypeRef { type_name }) => { + assert_eq!(&format!("{type_name}"), "T"); + }); + }) } + /// In this schema, the tag type is an entity type #[test] - fn value_type_is_common_type() { - let src = serde_json::json!({ - "": { - "commonTypes": { - "blah": { - "type": "Record", - "attributes": { - "foo": { "type": "String" }, - } - }, - }, - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "attributes": { - "blah": { - "type": "Record", - "default": { - "type": "blah" - } - }, - } + fn tag_type_is_entity_type() { + let json = json!({"": { + "entityTypes": { + "User" : { + "shape" : { + "type" : "Record", + "attributes" : { + "jobLevel" : { + "type" : "Long" + }, } }, + "tags" : { "type" : "Entity", "name": "User" }, }, - "actions": {} - } - }); - assert_matches!(Fragment::from_json_value(src), Ok(frag) => { + }, + "actions": {} + }}); + assert_matches!(Fragment::from_json_value(json), Ok(frag) => { let user = frag.0.get(&None).unwrap().entity_types.get(&"User".parse().unwrap()).unwrap(); - assert_matches!(&user.shape, EntityAttributes::EntityAttributes(EntityAttributesInternal { attrs, .. }) => { - assert_entity_attr_has_type( - attrs.attributes.get("blah").unwrap(), - &EntityAttributeTypeInternal::EAMap { value_type: Type::CommonTypeRef { type_name: "blah".parse().unwrap() } }, - ); + assert_matches!(&user.tags, Some(Type::Type(TypeVariant::Entity{ name })) => { + assert_eq!(&format!("{name}"), "User"); }); - }); + }) } + /// This schema has `tags` inside `shape` instead of parallel to it #[test] - fn nested_ea_map() { - let src = serde_json::json!({ - "": { - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "attributes": { - "userDetails": { - "type": "Record", - "default": { - "type": "Record", - "default": { - "type": "String" - } - } - }, - } - } + fn bad_tags() { + let json = json!({"": { + "entityTypes": { + "User": { + "shape": { + "type": "Record", + "attributes": { + "jobLevel": { + "type": "Long" + }, + }, + "tags": { "type": "String" }, } }, - "actions": {} - } - }); - assert_matches!(Fragment::from_json_value(src.clone()), Err(e) => { - expect_err( - &src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("found an embedded attribute map type, but embedded attribute maps are not allowed in this position") - .build(), - ); - }); - } - - #[test] - fn bad_default() { - let src = serde_json::json!({ - "": { - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "attributes": { - "jobLevel": { - "type": "Long", - }, - "authTags": { - "type": "Foo", - "default": { - "type": "Set", - "element": "String", - } - } - } - } - }, - }, - "actions": {} - } - }); - assert_matches!(Fragment::from_json_value(src.clone()), Err(e) => { - expect_err( - &src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("unknown field `default`, there are no fields") - .build(), - ); - }); - } - - #[test] - fn missing_type_record() { - let src = serde_json::json!({ - "": { - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "attributes": { - "jobLevel": { - "type": "Long", - }, - "authTags": { - "default": { - "type": "Set", - "element": "String", - } - } - } - } - }, - }, - "actions": {} - } - }); - assert_matches!(Fragment::from_json_value(src.clone()), Err(e) => { - expect_err( - &src, - &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("missing field `type`").build(), - ); - }); - } - - #[test] - fn both_default_and_attributes() { - let src = serde_json::json!({ - "": { - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "attributes": { - "jobLevel": { - "type": "Long", - }, - "authTags": { - "type": "Record", - "attributes": { - "foo": { - "type": "String", - }, - }, - "default": { - "type": "Set", - "element": "String", - } - } - } - } - }, - }, - "actions": {} - } - }); - assert_matches!(Fragment::from_json_value(src.clone()), Err(e) => { + }, + "actions": {} + }}); + assert_matches!(Fragment::from_json_value(json.clone()), Err(e) => { expect_err( - &src, + &json, &miette::Report::new(e), - &ExpectedErrorMessageBuilder::error("fields `default` and `attributes` cannot exist on the same record type") + &ExpectedErrorMessageBuilder::error("unknown field `tags`, expected one of `type`, `element`, `attributes`, `additionalAttributes`, `name`") .build(), ); }); @@ -3476,12 +2636,11 @@ mod test_json_roundtrip { "a".parse().unwrap(), EntityType { member_of_types: vec!["a".parse().unwrap()], - shape: EntityAttributes::RecordAttributes(RecordOrContextAttributes( - Type::Type(TypeVariant::Record(RecordType { - attributes: BTreeMap::new(), - additional_attributes: false, - })), - )), + shape: AttributesOrContext(Type::Type(TypeVariant::Record(RecordType { + attributes: BTreeMap::new(), + additional_attributes: false, + }))), + tags: None, }, )]), actions: HashMap::from([( @@ -3491,7 +2650,12 @@ mod test_json_roundtrip { applies_to: Some(ApplySpec { resource_types: vec!["a".parse().unwrap()], principal_types: vec!["a".parse().unwrap()], - context: RecordOrContextAttributes::default(), + context: AttributesOrContext(Type::Type(TypeVariant::Record( + RecordType { + attributes: BTreeMap::new(), + additional_attributes: false, + }, + ))), }), member_of: None, }, @@ -3512,7 +2676,13 @@ mod test_json_roundtrip { "a".parse().unwrap(), EntityType { member_of_types: vec!["a".parse().unwrap()], - shape: EntityAttributes::default(), + shape: AttributesOrContext(Type::Type(TypeVariant::Record( + RecordType { + attributes: BTreeMap::new(), + additional_attributes: false, + }, + ))), + tags: None, }, )]), actions: HashMap::new(), @@ -3530,7 +2700,12 @@ mod test_json_roundtrip { applies_to: Some(ApplySpec { resource_types: vec!["foo::a".parse().unwrap()], principal_types: vec!["foo::a".parse().unwrap()], - context: RecordOrContextAttributes::default(), + context: AttributesOrContext(Type::Type(TypeVariant::Record( + RecordType { + attributes: BTreeMap::new(), + additional_attributes: false, + }, + ))), }), member_of: None, }, @@ -3613,7 +2788,7 @@ mod test_duplicates_error { } #[test] - #[should_panic(expected = "the key `Baz` occurs two or more times in the same JSON object")] + #[should_panic(expected = "invalid entry: found duplicate key")] fn record_type() { let src = r#"{ "Foo": { diff --git a/cedar-policy-validator/src/lib.rs b/cedar-policy-validator/src/lib.rs index ef176b57b9..de7b4ae484 100644 --- a/cedar-policy-validator/src/lib.rs +++ b/cedar-policy-validator/src/lib.rs @@ -155,8 +155,8 @@ impl Validator { } .into_iter() .flatten(); - let (type_errors, warnings) = self.typecheck_policy(p, mode); - (validation_errors.chain(type_errors), warnings) + let (errors, warnings) = self.typecheck_policy(p, mode); + (validation_errors.chain(errors), warnings) } /// Run relevant validations against a single template-linked policy, @@ -199,10 +199,10 @@ impl Validator { impl Iterator + 'a, ) { let typecheck = Typechecker::new(&self.schema, mode, t.id().clone()); - let mut type_errors = HashSet::new(); + let mut errors = HashSet::new(); let mut warnings = HashSet::new(); - typecheck.typecheck_policy(t, &mut type_errors, &mut warnings); - (type_errors.into_iter(), warnings.into_iter()) + typecheck.typecheck_policy(t, &mut errors, &mut warnings); + (errors.into_iter(), warnings.into_iter()) } } @@ -232,14 +232,16 @@ mod test { foo_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, ), ( bar_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, ), ], @@ -249,7 +251,7 @@ mod test { applies_to: Some(json_schema::ApplySpec { principal_types: vec!["foo_type".parse().unwrap()], resource_types: vec!["bar_type".parse().unwrap()], - context: json_schema::RecordOrContextAttributes::default(), + context: json_schema::AttributesOrContext::default(), }), member_of: None, attributes: None, diff --git a/cedar-policy-validator/src/rbac.rs b/cedar-policy-validator/src/rbac.rs index b337c1a9ef..0fb202f0b2 100644 --- a/cedar-policy-validator/src/rbac.rs +++ b/cedar-policy-validator/src/rbac.rs @@ -487,7 +487,8 @@ mod test { foo_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, )], [], @@ -521,7 +522,8 @@ mod test { "foo_type".parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, )], [], @@ -606,7 +608,8 @@ mod test { p_name.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, )], [], @@ -630,7 +633,8 @@ mod test { p_name.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, )], [], @@ -654,7 +658,8 @@ mod test { p_name.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, )], [], @@ -944,7 +949,8 @@ mod test { foo_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, )], [], @@ -977,14 +983,16 @@ mod test { principal_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, ), ( resource_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, ), ], @@ -994,7 +1002,7 @@ mod test { applies_to: Some(json_schema::ApplySpec { resource_types: vec![resource_type.parse().unwrap()], principal_types: vec![principal_type.parse().unwrap()], - context: json_schema::RecordOrContextAttributes::default(), + context: json_schema::AttributesOrContext::default(), }), member_of: Some(vec![]), attributes: None, @@ -1366,28 +1374,32 @@ mod test { principal_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, ), ( resource_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![resource_parent_type.parse().unwrap()], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, ), ( resource_parent_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![resource_grandparent_type.parse().unwrap()], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, ), ( resource_grandparent_type.parse().unwrap(), json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }, ), ], @@ -1398,7 +1410,7 @@ mod test { applies_to: Some(json_schema::ApplySpec { resource_types: vec![resource_type.parse().unwrap()], principal_types: vec![principal_type.parse().unwrap()], - context: json_schema::RecordOrContextAttributes::default(), + context: json_schema::AttributesOrContext::default(), }), member_of: Some(vec![json_schema::ActionEntityUID::new( None, diff --git a/cedar-policy-validator/src/schema.rs b/cedar-policy-validator/src/schema.rs index 4365977b68..746a7e285d 100644 --- a/cedar-policy-validator/src/schema.rs +++ b/cedar-policy-validator/src/schema.rs @@ -49,7 +49,6 @@ pub(crate) use action::ValidatorApplySpec; mod entity_type; pub use entity_type::ValidatorEntityType; mod namespace_def; -use namespace_def::try_entity_attributes_into_validator_type; pub(crate) use namespace_def::try_jsonschema_type_into_validator_type; pub use namespace_def::ValidatorNamespaceDef; mod raw_name; @@ -493,8 +492,8 @@ impl ValidatorSchema { // `check_for_undeclared`. let descendants = entity_children.remove(&name).unwrap_or_default(); let (attributes, open_attributes) = { - let unresolved = try_entity_attributes_into_validator_type( - entity_type.attributes, + let unresolved = try_jsonschema_type_into_validator_type( + entity_type.attributes.0, extensions, )?; Self::record_attributes_or_none( @@ -504,6 +503,16 @@ impl ValidatorSchema { ContextOrShape::EntityTypeShape(name.clone()), ))? }; + let tags = entity_type + .tags + .map(|tags| try_jsonschema_type_into_validator_type(tags, extensions)) + .transpose()? + .map(|unresolved| unresolved.resolve_common_type_refs(&common_types)) + .transpose()?; + #[cfg(not(feature = "entity-tags"))] + if tags.is_some() { + return Err(UnsupportedFeatureError(UnsupportedFeature::EntityTags).into()); + } Ok(( name.clone(), ValidatorEntityType { @@ -511,6 +520,8 @@ impl ValidatorSchema { descendants, attributes, open_attributes, + #[cfg(feature = "entity-tags")] + tags, }, )) }) @@ -1257,7 +1268,7 @@ impl<'a> CommonTypeResolver<'a> { .map(|(attr, attr_ty)| { Ok(( attr, - json_schema::RecordAttributeType { + json_schema::TypeOfAttribute { required: attr_ty.required, ty: Self::resolve_type(resolve_table, attr_ty.ty)?, }, @@ -1322,16 +1333,68 @@ pub(crate) mod test { use super::*; - /// Transform the output of functions like - /// `ValidatorSchema::from_cedarschema_str()`, which has type `(ValidatorSchema, impl Iterator<...>)`, - /// into `(ValidatorSchema, Vec<...>)`, which implements `Debug` and thus can be used with - /// `assert_matches`, `.unwrap_err()`, etc - pub fn collect_warnings( - r: std::result::Result<(A, impl Iterator), E>, - ) -> std::result::Result<(A, Vec), E> { - r.map(|(a, iter)| (a, iter.collect())) + pub(crate) mod utils { + use super::{CedarSchemaError, SchemaError, ValidatorEntityType, ValidatorSchema}; + use cedar_policy_core::extensions::Extensions; + + /// Transform the output of functions like + /// `ValidatorSchema::from_cedarschema_str()`, which has type `(ValidatorSchema, impl Iterator<...>)`, + /// into `(ValidatorSchema, Vec<...>)`, which implements `Debug` and thus can be used with + /// `assert_matches`, `.unwrap_err()`, etc + pub fn collect_warnings( + r: std::result::Result<(A, impl Iterator), E>, + ) -> std::result::Result<(A, Vec), E> { + r.map(|(a, iter)| (a, iter.collect())) + } + + /// Given an entity type as string, get the `ValidatorEntityType` from the + /// schema, panicking if it does not exist (or if `etype` fails to parse as + /// an entity type) + #[track_caller] + pub fn assert_entity_type_exists<'s>( + schema: &'s ValidatorSchema, + etype: &str, + ) -> &'s ValidatorEntityType { + schema.get_entity_type(&etype.parse().unwrap()).unwrap() + } + + #[track_caller] + pub fn assert_valid_cedar_schema(src: &str) -> ValidatorSchema { + match ValidatorSchema::from_cedarschema_str(src, Extensions::all_available()) { + Ok((schema, _)) => schema, + Err(e) => panic!("{:?}", miette::Report::new(e)), + } + } + + #[track_caller] + pub fn assert_invalid_cedar_schema(src: &str) { + match ValidatorSchema::from_cedarschema_str(src, Extensions::all_available()) { + Ok(_) => panic!("{src} should be an invalid schema"), + Err(CedarSchemaError::Parsing(_)) => {} + Err(e) => panic!("unexpected error: {:?}", miette::Report::new(e)), + } + } + + #[track_caller] + pub fn assert_valid_json_schema(json: serde_json::Value) -> ValidatorSchema { + match ValidatorSchema::from_json_value(json, Extensions::all_available()) { + Ok(schema) => schema, + Err(e) => panic!("{:?}", miette::Report::new(e)), + } + } + + #[track_caller] + pub fn assert_invalid_json_schema(json: serde_json::Value) { + match ValidatorSchema::from_json_value(json.clone(), Extensions::all_available()) { + Ok(_) => panic!("{json} should be an invalid schema"), + Err(SchemaError::JsonDeserialization(_)) => {} + Err(e) => panic!("unexpected error: {:?}", miette::Report::new(e)), + } + } } + use utils::*; + // Well-formed schema #[test] fn test_from_schema_file() { @@ -2020,15 +2083,9 @@ pub(crate) mod test { .unwrap(); let schema: ValidatorSchema = fragment.try_into().unwrap(); - assert!(schema - .get_entity_type(&"Foo::Bar::Baz".parse().unwrap()) - .is_some()); - assert!(schema - .get_entity_type(&"Bar::Foo::Baz".parse().unwrap()) - .is_some()); - assert!(schema - .get_entity_type(&"Biz::Baz".parse().unwrap()) - .is_some()); + assert_entity_type_exists(&schema, "Foo::Bar::Baz"); + assert_entity_type_exists(&schema, "Bar::Foo::Baz"); + assert_entity_type_exists(&schema, "Biz::Baz"); } #[test] @@ -2050,9 +2107,7 @@ pub(crate) mod test { .unwrap(); let schema: ValidatorSchema = fragment.try_into().unwrap(); - let buz = schema - .get_entity_type(&"Foo::Buz".parse().unwrap()) - .unwrap(); + let buz = assert_entity_type_exists(&schema, "Foo::Buz"); assert_eq!( buz.descendants, HashSet::from(["Bar::Baz".parse().unwrap()]) @@ -2086,9 +2141,7 @@ pub(crate) mod test { .unwrap(); let schema: ValidatorSchema = fragment.try_into().unwrap(); - let baz = schema - .get_entity_type(&"Bar::Baz".parse().unwrap()) - .unwrap(); + let baz = assert_entity_type_exists(&schema, "Bar::Baz"); assert_eq!( baz.attr("fiz").unwrap().attr_type, Type::named_entity_reference_from_str("Foo::Buz"), @@ -2433,7 +2486,7 @@ pub(crate) mod test { let view_photo = actions.entity(&action_uid); assert_eq!( view_photo.unwrap(), - &Entity::new_with_attr_partial_value(action_uid, HashMap::new(), HashSet::new()) + &Entity::new_with_attr_partial_value(action_uid, [], HashSet::new()) ); } @@ -2467,7 +2520,7 @@ pub(crate) mod test { view_photo_entity.unwrap(), &Entity::new_with_attr_partial_value( view_photo_uid, - HashMap::new(), + [], HashSet::from([view_uid.clone(), read_uid.clone()]) ) ); @@ -2475,17 +2528,13 @@ pub(crate) mod test { let view_entity = actions.entity(&view_uid); assert_eq!( view_entity.unwrap(), - &Entity::new_with_attr_partial_value( - view_uid, - HashMap::new(), - HashSet::from([read_uid.clone()]) - ) + &Entity::new_with_attr_partial_value(view_uid, [], HashSet::from([read_uid.clone()])) ); let read_entity = actions.entity(&read_uid); assert_eq!( read_entity.unwrap(), - &Entity::new_with_attr_partial_value(read_uid, HashMap::new(), HashSet::new()) + &Entity::new_with_attr_partial_value(read_uid, [], HashSet::new()) ); } @@ -2512,8 +2561,10 @@ pub(crate) mod test { view_photo.unwrap(), &Entity::new( action_uid, - HashMap::from([("attr".into(), RestrictedExpr::val("foo"))]), + [("attr".into(), RestrictedExpr::val("foo"))], HashSet::new(), + #[cfg(feature = "entity-tags")] + [], Extensions::none(), ) .unwrap(), @@ -2652,10 +2703,7 @@ pub(crate) mod test { ); let schema = ValidatorSchema::from_json_value(src.clone(), Extensions::all_available()).unwrap(); - let mut attributes = schema - .get_entity_type(&"Demo::User".parse().unwrap()) - .unwrap() - .attributes(); + let mut attributes = assert_entity_type_exists(&schema, "Demo::User").attributes(); let (attr_name, attr_ty) = attributes.next().unwrap(); assert_eq!(attr_name, "id"); assert_eq!(&attr_ty.attr_type, &Type::primitive_string()); @@ -2916,7 +2964,7 @@ pub(crate) mod test { #[track_caller] fn assert_invalid_json_schema(src: serde_json::Value) { let schema = ValidatorSchema::from_json_value(src, Extensions::all_available()); - assert_matches!(schema, Err(SchemaError::JsonDeserialization(e)) if e.to_smolstr().contains("Used reserved schema keyword")); + assert_matches!(schema, Err(SchemaError::JsonDeserialization(e)) if e.to_smolstr().contains("this is reserved and cannot be the basename of a common-type declaration")); } // Names like `Set`, `Record`, `Entity`, and Extension` are not allowed as common type names, as specified in #1070 and #1139. @@ -3439,6 +3487,14 @@ pub(crate) mod test { ); }); } + + #[test] + fn attr_named_tags() { + let src = r#" + entity E { tags: Set<{key: String, value: Set}> }; + "#; + assert_valid_cedar_schema(src); + } } #[cfg(test)] @@ -3446,8 +3502,8 @@ mod test_579; // located in separate file test_579.rs #[cfg(test)] mod test_rfc70 { - use super::{test::collect_warnings, CedarSchemaError}; - use super::{SchemaError, ValidatorSchema}; + use super::test::utils::*; + use super::ValidatorSchema; use crate::types::Type; use cedar_policy_core::{ extensions::Extensions, @@ -3456,40 +3512,6 @@ mod test_rfc70 { use cool_asserts::assert_matches; use serde_json::json; - #[track_caller] - fn assert_valid_cedar_schema(src: &str) -> ValidatorSchema { - match ValidatorSchema::from_cedarschema_str(src, Extensions::all_available()) { - Ok((schema, _)) => schema, - Err(e) => panic!("{:?}", miette::Report::new(e)), - } - } - - #[track_caller] - fn assert_invalid_cedar_schema(src: &str) { - match ValidatorSchema::from_cedarschema_str(src, Extensions::all_available()) { - Ok(_) => panic!("{src} should be an invalid schema"), - Err(CedarSchemaError::Parsing(_)) => {} - Err(e) => panic!("unexpected error: {:?}", miette::Report::new(e)), - } - } - - #[track_caller] - fn assert_valid_json_schema(json: serde_json::Value) -> ValidatorSchema { - match ValidatorSchema::from_json_value(json, Extensions::all_available()) { - Ok(schema) => schema, - Err(e) => panic!("{:?}", miette::Report::new(e)), - } - } - - #[track_caller] - fn assert_invalid_json_schema(json: serde_json::Value) { - match ValidatorSchema::from_json_value(json.clone(), Extensions::all_available()) { - Ok(_) => panic!("{json} should be an invalid schema"), - Err(SchemaError::JsonDeserialization(_)) => {} - Err(e) => panic!("unexpected error: {:?}", miette::Report::new(e)), - } - } - /// Common type shadowing a common type is disallowed in both syntaxes #[test] fn common_common_conflict() { @@ -4044,7 +4066,7 @@ mod test_rfc70 { } "; let schema = assert_valid_cedar_schema(src); - let e = schema.get_entity_type(&"E".parse().unwrap()).unwrap(); + let e = assert_entity_type_exists(&schema, "E"); assert_matches!(e.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); // using the common type definition }); @@ -4057,7 +4079,7 @@ mod test_rfc70 { assert_matches!(e.attributes.get_attr("d"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); }); - let f = schema.get_entity_type(&"NS::F".parse().unwrap()).unwrap(); + let f = assert_entity_type_exists(&schema, "NS::F"); assert_matches!(f.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); // using the common type definition }); @@ -4153,7 +4175,7 @@ mod test_rfc70 { } }); let schema = assert_valid_json_schema(src_json); - let e = schema.get_entity_type(&"E".parse().unwrap()).unwrap(); + let e = assert_entity_type_exists(&schema, "E"); assert_matches!(e.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); }); @@ -4166,7 +4188,7 @@ mod test_rfc70 { assert_matches!(e.attributes.get_attr("d"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); }); - let f = schema.get_entity_type(&"NS::F".parse().unwrap()).unwrap(); + let f = assert_entity_type_exists(&schema, "NS::F"); assert_matches!(f.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); // using the common type definition }); @@ -4204,7 +4226,7 @@ mod test_rfc70 { } "; let schema = assert_valid_cedar_schema(src); - let e = schema.get_entity_type(&"E".parse().unwrap()).unwrap(); + let e = assert_entity_type_exists(&schema, "E"); assert_matches!(e.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); // using the common type definition }); @@ -4217,7 +4239,7 @@ mod test_rfc70 { assert_matches!(e.attributes.get_attr("d"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); }); - let f = schema.get_entity_type(&"NS::F".parse().unwrap()).unwrap(); + let f = assert_entity_type_exists(&schema, "NS::F"); assert_matches!(f.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); // using the common type definition }); @@ -4272,7 +4294,7 @@ mod test_rfc70 { } }); let schema = assert_valid_json_schema(src_json); - let e = schema.get_entity_type(&"E".parse().unwrap()).unwrap(); + let e = assert_entity_type_exists(&schema, "E"); assert_matches!(e.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); // using the common type definition }); @@ -4285,7 +4307,7 @@ mod test_rfc70 { assert_matches!(e.attributes.get_attr("d"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); }); - let f = schema.get_entity_type(&"NS::F".parse().unwrap()).unwrap(); + let f = assert_entity_type_exists(&schema, "NS::F"); assert_matches!(f.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_long()); // using the common type definition }); @@ -4319,14 +4341,14 @@ mod test_rfc70 { } "; let schema = assert_valid_cedar_schema(src); - let e = schema.get_entity_type(&"E".parse().unwrap()).unwrap(); + let e = assert_entity_type_exists(&schema, "E"); assert_matches!(e.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::named_entity_reference_from_str("String")); }); assert_matches!(e.attributes.get_attr("b"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_string()); }); - let f = schema.get_entity_type(&"NS::F".parse().unwrap()).unwrap(); + let f = assert_entity_type_exists(&schema, "NS::F"); assert_matches!(f.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::named_entity_reference_from_str("NS::Bool")); // using the common type definition }); @@ -4367,14 +4389,14 @@ mod test_rfc70 { } }); let schema = assert_valid_json_schema(src_json); - let e = schema.get_entity_type(&"E".parse().unwrap()).unwrap(); + let e = assert_entity_type_exists(&schema, "E"); assert_matches!(e.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::named_entity_reference_from_str("String")); }); assert_matches!(e.attributes.get_attr("b"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::primitive_string()); }); - let f = schema.get_entity_type(&"NS::F".parse().unwrap()).unwrap(); + let f = assert_entity_type_exists(&schema, "NS::F"); assert_matches!(f.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::named_entity_reference_from_str("NS::Bool")); }); @@ -4402,14 +4424,14 @@ mod test_rfc70 { } "; let schema = assert_valid_cedar_schema(src); - let e = schema.get_entity_type(&"E".parse().unwrap()).unwrap(); + let e = assert_entity_type_exists(&schema, "E"); assert_matches!(e.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::named_entity_reference_from_str("ipaddr")); }); assert_matches!(e.attributes.get_attr("b"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::extension("ipaddr".parse().unwrap())); }); - let f = schema.get_entity_type(&"NS::F".parse().unwrap()).unwrap(); + let f = assert_entity_type_exists(&schema, "NS::F"); assert_matches!(f.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::named_entity_reference_from_str("NS::decimal")); }); @@ -4450,14 +4472,14 @@ mod test_rfc70 { } }); let schema = assert_valid_json_schema(src_json); - let e = schema.get_entity_type(&"E".parse().unwrap()).unwrap(); + let e = assert_entity_type_exists(&schema, "E"); assert_matches!(e.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::named_entity_reference_from_str("ipaddr")); }); assert_matches!(e.attributes.get_attr("b"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::extension("ipaddr".parse().unwrap())); }); - let f = schema.get_entity_type(&"NS::F".parse().unwrap()).unwrap(); + let f = assert_entity_type_exists(&schema, "NS::F"); assert_matches!(f.attributes.get_attr("a"), Some(atype) => { assert_eq!(&atype.attr_type, &Type::named_entity_reference_from_str("NS::decimal")); }); @@ -4467,6 +4489,185 @@ mod test_rfc70 { } } +/// Tests involving entity tags (RFC 82) +#[cfg(test)] +mod entity_tags { + use super::{test::utils::*, *}; + use cedar_policy_core::{ + extensions::Extensions, + test_utils::{expect_err, ExpectedErrorMessageBuilder}, + }; + use cool_asserts::assert_matches; + use serde_json::json; + + #[cfg(feature = "entity-tags")] + use crate::types::Primitive; + + #[test] + fn cedar_syntax_tags() { + // This schema taken directly from the RFC 82 text + let src = " + entity User = { + jobLevel: Long, + } tags Set; + entity Document = { + owner: User, + } tags Set; + "; + #[cfg(feature = "entity-tags")] + assert_matches!(collect_warnings(ValidatorSchema::from_cedarschema_str(src, &Extensions::all_available())), Ok((schema, warnings)) => { + assert!(warnings.is_empty()); + let user = assert_entity_type_exists(&schema, "User"); + assert_matches!(user.tag_type(), Some(Type::Set { element_type: Some(el_ty) }) => { + assert_matches!(&**el_ty, Type::Primitive { primitive_type: Primitive::String }); + }); + let doc = assert_entity_type_exists(&schema, "Document"); + assert_matches!(doc.tag_type(), Some(Type::Set { element_type: Some(el_ty) }) => { + assert_matches!(&**el_ty, Type::Primitive { primitive_type: Primitive::String }); + }); + }); + #[cfg(not(feature = "entity-tags"))] + assert_matches!(collect_warnings(ValidatorSchema::from_cedarschema_str(src, &Extensions::all_available())), Err(e) => { + expect_err( + src, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("unsupported feature used in schema") + .source("entity tags are not supported in this build; to use entity tags, you must enable the `entity-tags` experimental feature") + .build(), + ); + }); + } + + #[test] + fn json_syntax_tags() { + // This schema taken directly from the RFC 82 text + let json = json!({"": { + "entityTypes": { + "User" : { + "shape" : { + "type" : "Record", + "attributes" : { + "jobLevel" : { + "type" : "Long" + }, + } + }, + "tags" : { + "type" : "Set", + "element": { "type": "String" } + } + }, + "Document" : { + "shape" : { + "type" : "Record", + "attributes" : { + "owner" : { + "type" : "Entity", + "name" : "User" + }, + } + }, + "tags" : { + "type" : "Set", + "element": { "type": "String" } + } + } + }, + "actions": {} + }}); + #[cfg(feature = "entity-tags")] + assert_matches!(ValidatorSchema::from_json_value(json.clone(), &Extensions::all_available()), Ok(schema) => { + let user = assert_entity_type_exists(&schema, "User"); + assert_matches!(user.tag_type(), Some(Type::Set { element_type: Some(el_ty) }) => { + assert_matches!(&**el_ty, Type::Primitive { primitive_type: Primitive::String }); + }); + let doc = assert_entity_type_exists(&schema, "Document"); + assert_matches!(doc.tag_type(), Some(Type::Set { element_type: Some(el_ty) }) => { + assert_matches!(&**el_ty, Type::Primitive { primitive_type: Primitive::String }); + }); + }); + #[cfg(not(feature = "entity-tags"))] + assert_matches!(ValidatorSchema::from_json_value(json.clone(), &Extensions::all_available()), Err(e) => { + expect_err( + &json, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("unsupported feature used in schema") + .source("entity tags are not supported in this build; to use entity tags, you must enable the `entity-tags` experimental feature") + .build(), + ); + }); + } + + #[test] + fn other_tag_types() { + let src = " + entity E; + type Blah = { + foo: Long, + bar: Set, + }; + entity Foo1 in E { + bool: Bool, + } tags Bool; + entity Foo2 in E { + bool: Bool, + } tags { bool: Bool }; + entity Foo3 in E tags E; + entity Foo4 in E tags Set; + entity Foo5 in E tags { a: String, b: Long }; + entity Foo6 in E tags Blah; + entity Foo7 in E tags Set>; + entity Foo8 in E tags Foo7; + "; + #[cfg(feature = "entity-tags")] + assert_matches!(collect_warnings(ValidatorSchema::from_cedarschema_str(src, &Extensions::all_available())), Ok((schema, warnings)) => { + assert!(warnings.is_empty()); + let e = assert_entity_type_exists(&schema, "E"); + assert_matches!(e.tag_type(), None); + let foo1 = assert_entity_type_exists(&schema, "Foo1"); + assert_matches!(foo1.tag_type(), Some(Type::Primitive { primitive_type: Primitive::Bool })); + let foo2 = assert_entity_type_exists(&schema, "Foo2"); + assert_matches!(foo2.tag_type(), Some(Type::EntityOrRecord(EntityRecordKind::Record { .. }))); + let foo3 = assert_entity_type_exists(&schema, "Foo3"); + assert_matches!(foo3.tag_type(), Some(Type::EntityOrRecord(EntityRecordKind::Entity(_)))); + let foo4 = assert_entity_type_exists(&schema, "Foo4"); + assert_matches!(foo4.tag_type(), Some(Type::Set { element_type }) => assert_matches!(element_type.as_deref(), Some(Type::EntityOrRecord(EntityRecordKind::Entity(_))))); + let foo5 = assert_entity_type_exists(&schema, "Foo5"); + assert_matches!(foo5.tag_type(), Some(Type::EntityOrRecord(EntityRecordKind::Record { .. }))); + let foo6 = assert_entity_type_exists(&schema, "Foo6"); + assert_matches!(foo6.tag_type(), Some(Type::EntityOrRecord(EntityRecordKind::Record { .. }))); + let foo7 = assert_entity_type_exists(&schema, "Foo7"); + assert_matches!(foo7.tag_type(), Some(Type::Set { element_type }) => assert_matches!(element_type.as_deref(), Some(Type::Set { element_type }) => assert_matches!(element_type.as_deref(), Some(Type::EntityOrRecord(EntityRecordKind::Record { .. }))))); + let foo8 = assert_entity_type_exists(&schema, "Foo8"); + assert_matches!(foo8.tag_type(), Some(Type::EntityOrRecord(EntityRecordKind::Entity(_)))); + }); + #[cfg(not(feature = "entity-tags"))] + assert_matches!(collect_warnings(ValidatorSchema::from_cedarschema_str(src, &Extensions::all_available())), Err(e) => { + expect_err( + src, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("unsupported feature used in schema") + .source("entity tags are not supported in this build; to use entity tags, you must enable the `entity-tags` experimental feature") + .build(), + ); + }); + } + + #[test] + fn invalid_tags() { + let src = "entity E tags Undef;"; + assert_matches!(collect_warnings(ValidatorSchema::from_cedarschema_str(src, &Extensions::all_available())), Err(e) => { + expect_err( + src, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error("failed to resolve type: Undef") + .help("`Undef` has not been declared as a common or entity type") + .build(), + ); + }); + } +} + #[cfg(test)] mod test_resolver { use std::collections::HashMap; diff --git a/cedar-policy-validator/src/schema/entity_type.rs b/cedar-policy-validator/src/schema/entity_type.rs index 4fa5519663..2f88027a56 100644 --- a/cedar-policy-validator/src/schema/entity_type.rs +++ b/cedar-policy-validator/src/schema/entity_type.rs @@ -22,6 +22,8 @@ use std::collections::HashSet; use cedar_policy_core::{ast::EntityType, transitive_closure::TCNode}; +#[cfg(feature = "entity-tags")] +use crate::types::Type; use crate::types::{AttributeType, Attributes, OpenTag}; /// Contains entity type information for use by the validator. The contents of @@ -47,6 +49,11 @@ pub struct ValidatorEntityType { /// their type when they are present. Attempting to access an undeclared /// attribute under standard validation is an error regardless of this flag. pub(crate) open_attributes: OpenTag, + + /// Tag type for this entity type. `None` indicates that entities of this + /// type are not allowed to have tags. + #[cfg(feature = "entity-tags")] + pub(crate) tags: Option, } impl ValidatorEntityType { @@ -65,6 +72,13 @@ impl ValidatorEntityType { pub fn has_descendant_entity_type(&self, ety: &EntityType) -> bool { self.descendants.contains(ety) } + + /// Get the type of tags on this entity. `None` indicates that entities of + /// this type are not allowed to have tags. + #[cfg(feature = "entity-tags")] + pub fn tag_type(&self) -> Option<&Type> { + self.tags.as_ref() + } } impl TCNode for ValidatorEntityType { diff --git a/cedar-policy-validator/src/schema/namespace_def.rs b/cedar-policy-validator/src/schema/namespace_def.rs index 805a853395..6919185852 100644 --- a/cedar-policy-validator/src/schema/namespace_def.rs +++ b/cedar-policy-validator/src/schema/namespace_def.rs @@ -29,7 +29,7 @@ use cedar_policy_core::{ extensions::Extensions, }; use itertools::Itertools; -use nonempty::NonEmpty; +use nonempty::{nonempty, NonEmpty}; use smol_str::{SmolStr, ToSmolStr}; use super::{internal_name_to_entity_type, AllDefs, ValidatorApplySpec}; @@ -436,24 +436,34 @@ impl EntityTypesDef { /// Holds the attributes and parents information for an entity type definition. /// /// In this representation, references to common types may not yet have been -/// fully resolved/inlined, and both `parents` and `attributes` may reference -/// undeclared entity/common types. Furthermore, entity/common type references -/// in `attributes` may or may not be fully qualified yet, depending on `N`. +/// fully resolved/inlined, and `parents`, `attributes`, and `tags` may all +/// reference undeclared entity/common types. Furthermore, entity/common type +/// references in `parents`, `attributes`, and `tags` may or may not be fully +/// qualified yet, depending on `N`. #[derive(Debug)] pub struct EntityTypeFragment { - /// Description of the attribute types for this entity type. This may - /// contain references to common types which have not yet been + /// Description of the attribute types for this entity type. + /// + /// This may contain references to common types which have not yet been /// resolved/inlined (e.g., because they are not defined in this schema /// fragment). /// In the extreme case, this may itself be just a common type pointing to a /// `Record` type defined in another fragment. - pub(super) attributes: json_schema::EntityAttributes, + pub(super) attributes: json_schema::AttributesOrContext, /// Direct parent entity types for this entity type. /// These entity types may be declared in a different namespace or schema /// fragment. + /// /// We will check for undeclared parent types when combining fragments into /// a [`crate::ValidatorSchema`]. pub(super) parents: HashSet, + /// Tag type for this entity type. `None` means no tags are allowed on this + /// entity type. + /// + /// This may contain references to common types which have not yet been + /// resolved/inlined (e.g., because they are not defined in this schema + /// fragment). + pub(super) tags: Option>, } impl EntityTypeFragment { @@ -476,6 +486,9 @@ impl EntityTypeFragment { raw_name.conditionally_qualify_with(schema_namespace, ReferenceType::Entity) }) .collect(), + tags: schema_file_type + .tags + .map(|tags| tags.conditionally_qualify_type_references(schema_namespace)), } } @@ -497,6 +510,11 @@ impl EntityTypeFragment { .into_iter() .map(|parent| parent.resolve(all_defs)) .collect::>()?; + // Fully qualify typenames appearing in `tags` + let fully_qual_tags = self + .tags + .map(|tags| tags.fully_qualify_type_references(all_defs)) + .transpose(); // Now is the time to check whether any parents are dangling, i.e., // refer to entity types that are not declared in any fragment (since we // now have the set of typenames that are declared in all fragments). @@ -506,17 +524,26 @@ impl EntityTypeFragment { .filter(|ety| !all_defs.is_defined_as_entity(ety)) .map(|ety| ConditionalName::unconditional(ety.clone(), ReferenceType::Entity)), ); - match (fully_qual_attributes, undeclared_parents) { - (Ok(attributes), None) => Ok(EntityTypeFragment { + match (fully_qual_attributes, fully_qual_tags, undeclared_parents) { + (Ok(attributes), Ok(tags), None) => Ok(EntityTypeFragment { attributes, parents, + tags, }), - (Ok(_), Some(undeclared_parents)) => Err(TypeNotDefinedError(undeclared_parents)), - (Err(e), None) => Err(e), - (Err(e), Some(mut undeclared)) => { + (Ok(_), Ok(_), Some(undeclared_parents)) => { + Err(TypeNotDefinedError(undeclared_parents)) + } + (Err(e), Ok(_), None) | (Ok(_), Err(e), None) => Err(e), + (Err(e1), Err(e2), None) => Err(TypeNotDefinedError::join_nonempty(nonempty![e1, e2])), + (Err(e), Ok(_), Some(mut undeclared)) | (Ok(_), Err(e), Some(mut undeclared)) => { undeclared.extend(e.0); Err(TypeNotDefinedError(undeclared)) } + (Err(e1), Err(e2), Some(mut undeclared)) => { + undeclared.extend(e1.0); + undeclared.extend(e2.0); + Err(TypeNotDefinedError(undeclared)) + } } } } @@ -742,7 +769,8 @@ impl ActionFragment { .map_err(|err| { ActionAttrEvalError(EntityAttrEvaluationError { uid: action_id.clone(), - attr: k.clone(), + attr_or_tag: k.clone(), + was_attr: true, err, }) })?; @@ -921,7 +949,7 @@ pub(crate) fn try_jsonschema_type_into_validator_type( Ok(try_jsonschema_type_into_validator_type(*element, extensions)?.map(Type::set)) } json_schema::Type::Type(json_schema::TypeVariant::Record(rty)) => { - try_record_record_type_into_validator_type(rty, extensions) + try_record_type_into_validator_type(rty, extensions) } json_schema::Type::Type(json_schema::TypeVariant::Entity { name }) => { Ok(Type::named_entity_reference(internal_name_to_entity_type(name)?).into()) @@ -985,10 +1013,10 @@ pub(crate) fn try_jsonschema_type_into_validator_type( } } -/// Convert a [`json_schema::RecordType`] -/// (with fully qualified names) into the [`Type`] type used by the validator. -pub(crate) fn try_record_record_type_into_validator_type( - rty: json_schema::RecordType>, +/// Convert a [`json_schema::RecordType`] (with fully qualified names) into the +/// [`Type`] type used by the validator. +pub(crate) fn try_record_type_into_validator_type( + rty: json_schema::RecordType, extensions: &Extensions<'_>, ) -> crate::err::Result> { if cfg!(not(feature = "partial-validate")) && rty.additional_attributes { @@ -1009,52 +1037,12 @@ pub(crate) fn try_record_record_type_into_validator_type( } } -/// Convert a [`json_schema::RecordType`] -/// (with fully qualified names) into the [`Type`] type used by the validator. -pub(crate) fn try_record_entity_type_into_validator_type( - rty: json_schema::RecordType>, - extensions: &Extensions<'_>, -) -> crate::err::Result> { - if cfg!(not(feature = "partial-validate")) && rty.additional_attributes { - Err(UnsupportedFeatureError(UnsupportedFeature::OpenRecordsAndEntities).into()) - } else { - Ok( - parse_entity_attributes(rty.attributes, extensions)?.map(move |attrs| { - Type::record_with_attributes( - attrs, - if rty.additional_attributes { - OpenTag::OpenAttributes - } else { - OpenTag::ClosedAttributes - }, - ) - }), - ) - } -} - -/// Convert a [`json_schema::EntityAttributes`] (with fully qualified names) -/// into the [`Type`] type used by the validator. -pub(crate) fn try_entity_attributes_into_validator_type( - attrs: json_schema::EntityAttributes, - extensions: &Extensions<'_>, -) -> crate::err::Result> { - match attrs { - json_schema::EntityAttributes::RecordAttributes(attrs) => { - try_jsonschema_type_into_validator_type(attrs.0, extensions) - } - json_schema::EntityAttributes::EntityAttributes( - json_schema::EntityAttributesInternal { attrs, .. }, - ) => try_record_entity_type_into_validator_type(attrs, extensions), - } -} - -/// Given the attributes for a record type in the schema file format structures -/// (but with fully-qualified names), convert the types of the attributes into -/// the [`Type`] data structure used by the validator, and return the result as -/// an [`Attributes`] structure. +/// Given the attributes for an entity or record type in the schema file format +/// structures (but with fully-qualified names), convert the types of the +/// attributes into the [`Type`] data structure used by the validator, and +/// return the result as an [`Attributes`] structure. fn parse_record_attributes( - attrs: impl IntoIterator)>, + attrs: impl IntoIterator)>, extensions: &Extensions<'_>, ) -> crate::err::Result> { let attrs_with_common_type_refs = attrs @@ -1069,45 +1057,8 @@ fn parse_record_attributes( )) }) .collect::>>()?; - Ok(attributes_from_attributes(attrs_with_common_type_refs)) -} - -/// Given the attributes for an entity type in the schema file format structures -/// (but with fully-qualified names), convert the types of the attributes into -/// the [`Type`] data structure used by the validator, and return the result as -/// an [`Attributes`] structure. -fn parse_entity_attributes( - attrs: impl IntoIterator)>, - extensions: &Extensions<'_>, -) -> crate::err::Result> { - let attrs_with_common_type_refs = attrs - .into_iter() - .map(|(attr, ty)| -> crate::err::Result<_> { - match ty.ty { - json_schema::EntityAttributeTypeInternal::Type(inner_ty) => { - let validator_type = - try_jsonschema_type_into_validator_type(inner_ty, extensions)?; - Ok((attr, (validator_type, ty.required))) - } - json_schema::EntityAttributeTypeInternal::EAMap { .. } => { - // This will be implemented in the next RFC 68 PR, which will - // introduce the necessary changes to [`Attributes`] and its - // associated types - Err(UnsupportedFeatureError(UnsupportedFeature::EAMaps).into()) - } - } - }) - .collect::>>()?; - Ok(attributes_from_attributes(attrs_with_common_type_refs)) -} - -/// Given an iterator of attribute types `(attribute name, (attribute type, is_required))`, -/// build an [`Attributes`] structure. -fn attributes_from_attributes( - attrs: impl IntoIterator, bool))> + 'static, -) -> WithUnresolvedCommonTypeRefs { - WithUnresolvedCommonTypeRefs::new(|common_type_defs| { - attrs + Ok(WithUnresolvedCommonTypeRefs::new(|common_type_defs| { + attrs_with_common_type_refs .into_iter() .map(|(s, (attr_ty, is_req))| { attr_ty @@ -1116,5 +1067,5 @@ fn attributes_from_attributes( }) .collect::>>() .map(Attributes::with_attributes) - }) + })) } diff --git a/cedar-policy-validator/src/schema/test_579.rs b/cedar-policy-validator/src/schema/test_579.rs index 8362b82ce1..05520c8cc2 100644 --- a/cedar-policy-validator/src/schema/test_579.rs +++ b/cedar-policy-validator/src/schema/test_579.rs @@ -60,7 +60,7 @@ //! we only do the test for the more sensible one. (For instance, for 1a1, we //! only test an entity type reference, not a common type reference.) -use super::{test::collect_warnings, SchemaWarning, ValidatorSchema}; +use super::{test::utils::collect_warnings, SchemaWarning, ValidatorSchema}; use cedar_policy_core::extensions::Extensions; use cedar_policy_core::test_utils::{ expect_err, ExpectedErrorMessage, ExpectedErrorMessageBuilder, diff --git a/cedar-policy-validator/src/typecheck.rs b/cedar-policy-validator/src/typecheck.rs index e720f8e4ca..a1038f2e9a 100644 --- a/cedar-policy-validator/src/typecheck.rs +++ b/cedar-policy-validator/src/typecheck.rs @@ -812,7 +812,8 @@ impl<'a> Typechecker<'a> { // evaluate to `true` when the attribute is // present). if ty.is_required - || prior_capability.contains(&Capability::new(expr, attr)) + || prior_capability + .contains(&Capability::new_attribute(expr, attr.clone())) { TypecheckAnswer::success(annot_expr) } else { @@ -904,8 +905,8 @@ impl<'a> Typechecker<'a> { // However, we can make an exception when the attribute // access of the expression is already in the prior capability, // which means the entity must exist. - let in_prior_capability = - prior_capability.contains(&Capability::new(expr, attr)); + let in_prior_capability = prior_capability + .contains(&Capability::new_attribute(expr, attr.clone())); let type_of_has = if exists_in_store || in_prior_capability { Type::singleton_boolean(true) } else { @@ -915,7 +916,10 @@ impl<'a> Typechecker<'a> { ExprBuilder::with_data(Some(type_of_has)) .with_same_source_loc(e) .has_attr(typ_expr_actual, attr.clone()), - CapabilitySet::singleton(Capability::new(expr, attr)), + CapabilitySet::singleton(Capability::new_attribute( + expr, + attr.clone(), + )), ) } // This is where capability information is generated. If @@ -931,7 +935,9 @@ impl<'a> Typechecker<'a> { // type `true` if it occurs after the attribute // access of the expression is already in the // prior capability. - if prior_capability.contains(&Capability::new(expr, attr)) { + if prior_capability + .contains(&Capability::new_attribute(expr, attr.clone())) + { Type::singleton_boolean(true) } else { Type::primitive_boolean() @@ -939,7 +945,10 @@ impl<'a> Typechecker<'a> { )) .with_same_source_loc(e) .has_attr(typ_expr_actual, attr.clone()), - CapabilitySet::singleton(Capability::new(expr, attr)), + CapabilitySet::singleton(Capability::new_attribute( + expr, + attr.clone(), + )), ), None => TypecheckAnswer::success( ExprBuilder::with_data(Some( @@ -1417,6 +1426,221 @@ impl<'a> Typechecker<'a> { }) }) } + + #[cfg(feature = "entity-tags")] + BinaryOp::HasTag => self + .expect_type( + request_env, + prior_capability, + arg1, + Type::any_entity_reference(), + type_errors, + |_| None, + ) + .then_typecheck(|expr_ty_arg1, _| { + self.expect_type( + request_env, + prior_capability, + arg2, + Type::primitive_string(), + type_errors, + |_| None, + ) + .then_typecheck(|expr_ty_arg2, _| { + let kind = match expr_ty_arg1.data() { + Some(Type::EntityOrRecord(kind)) => kind, + None => { + // should have already reported an error in this case. + // just return a failure. + return TypecheckAnswer::fail( + ExprBuilder::new() + .with_same_source_loc(bin_expr) + .has_tag(expr_ty_arg1, expr_ty_arg2), + ); + } + _ => { + // should be unreachable, as we already typechecked that this matches + // `Type::any_entity_reference()` + type_errors.push(ValidationError::internal_invariant_violation( + bin_expr.source_loc().cloned(), + self.policy_id.clone(), + )); + return TypecheckAnswer::fail( + ExprBuilder::new() + .with_same_source_loc(bin_expr) + .has_tag(expr_ty_arg1, expr_ty_arg2), + ); + } + }; + let type_of_has = match self.tag_types(kind) { + Ok(tag_types) if tag_types.is_empty() => { + // impossible for the type to have any tags, thus the `has` will always be `False` + Type::singleton_boolean(false) + } + Err(()) => { + // Not an entity type; should be unreachable, as we already typechecked + // that this matches `Type::any_entity_reference()` + type_errors.push(ValidationError::internal_invariant_violation( + bin_expr.source_loc().cloned(), + self.policy_id.clone(), + )); + return TypecheckAnswer::fail( + ExprBuilder::new() + .with_same_source_loc(bin_expr) + .has_tag(expr_ty_arg1, expr_ty_arg2), + ); + } + _ => Type::primitive_boolean(), + }; + TypecheckAnswer::success_with_capability( + ExprBuilder::with_data(Some(type_of_has)) + .with_same_source_loc(bin_expr) + .binary_app(BinaryOp::HasTag, expr_ty_arg1, expr_ty_arg2), + CapabilitySet::singleton(Capability::new_borrowed_tag(arg1, &arg2)), + ) + }) + }), + + #[cfg(feature = "entity-tags")] + BinaryOp::GetTag => { + self.expect_type( + request_env, + prior_capability, + arg1, + Type::any_entity_reference(), + type_errors, + |actual| match actual { + _ => None, + }, + ) + .then_typecheck(|expr_ty_arg1, _| { + self.expect_type( + request_env, + prior_capability, + arg2, + Type::primitive_string(), + type_errors, + |_| None, + ) + .then_typecheck(|expr_ty_arg2, _| { + let kind = match expr_ty_arg1.data() { + Some(Type::EntityOrRecord(kind)) => kind, + None => { + // should have already reported an error in this case. + // just return a failure. + return TypecheckAnswer::fail( + ExprBuilder::new() + .with_same_source_loc(bin_expr) + .get_tag(expr_ty_arg1, expr_ty_arg2), + ); + } + _ => { + // should be unreachable, as we already typechecked that this matches + // `Type::any_entity_reference()` + type_errors.push(ValidationError::internal_invariant_violation( + bin_expr.source_loc().cloned(), + self.policy_id.clone(), + )); + return TypecheckAnswer::fail( + ExprBuilder::new() + .with_same_source_loc(bin_expr) + .get_tag(expr_ty_arg1, expr_ty_arg2), + ); + } + }; + if prior_capability.contains(&Capability::new_borrowed_tag(arg1, &arg2)) { + // Determine the set of possible tag types for this access. + let tag_types = match self.tag_types(kind) { + Ok(tag_types) => tag_types, + Err(()) => { + // `kind` was not an entity type. + // should be unreachable, as we already typechecked that this matches + // `Type::any_entity_reference()` + type_errors.push( + ValidationError::internal_invariant_violation( + bin_expr.source_loc().cloned(), + self.policy_id.clone(), + ), + ); + return TypecheckAnswer::fail( + ExprBuilder::new() + .with_same_source_loc(bin_expr) + .get_tag(expr_ty_arg1, expr_ty_arg2), + ); + } + }; + if tag_types.is_empty() { + // no entities in the LUB are allowed to have tags. + // This is a somewhat weird case where we did do a `has` check (we + // already confirmed farther above that we have the capability for + // this tag), but the entity type(s) we're operating on just can't + // have tags. + let entity_ty = match kind { + EntityRecordKind::Entity(lub) => lub.get_single_entity(), + EntityRecordKind::AnyEntity => None, + EntityRecordKind::ActionEntity { name, .. } => Some(name), + EntityRecordKind::Record { .. } => None, + }; + type_errors.push(ValidationError::no_tags_allowed( + bin_expr.source_loc().cloned(), + self.policy_id.clone(), + entity_ty.cloned(), + )); + TypecheckAnswer::fail( + ExprBuilder::new() + .with_same_source_loc(bin_expr) + .get_tag(expr_ty_arg1, expr_ty_arg2), + ) + } else { + // one or more entities in the LUB are allowed to have tags. + // compute the LUB of all the relevant tag types, and assign that + // as the type. + let tag_type = match Type::reduce_to_least_upper_bound( + &self.schema, + tag_types.clone(), + self.mode, + ) { + Ok(ty) => ty, + Err(e) => { + type_errors.push(ValidationError::incompatible_types( + bin_expr.source_loc().cloned(), + self.policy_id.clone(), + tag_types.into_iter().cloned(), + e, + LubContext::GetTag, + )); + return TypecheckAnswer::fail( + ExprBuilder::new() + .with_same_source_loc(bin_expr) + .get_tag(expr_ty_arg1, expr_ty_arg2), + ); + } + }; + TypecheckAnswer::success( + ExprBuilder::with_data(Some(tag_type)) + .with_same_source_loc(bin_expr) + .get_tag(expr_ty_arg1, expr_ty_arg2), + ) + } + } else { + type_errors.push(ValidationError::unsafe_tag_access( + bin_expr.source_loc().cloned(), + self.policy_id.clone(), + match kind { + EntityRecordKind::Entity(lub) => Some(lub.clone()), + _ => None, + }, + expr_ty_arg2.clone(), + )); + TypecheckAnswer::fail( + ExprBuilder::new() + .with_same_source_loc(bin_expr) + .get_tag(expr_ty_arg1, expr_ty_arg2), + ) + } + }) + }) + } } } @@ -1506,6 +1730,37 @@ impl<'a> Typechecker<'a> { } } + /// Get the set of types that are possible tag types for `kind`. + /// + /// If `kind` is not an entity type (e.g., a record type), this returns `Err`. + /// If `kind` is an entity type without a `tags` declaration, this returns + /// `Ok` with the empty set. + /// + /// If `kind` is a LUB containing some entity types that have tags and some + /// that do not, this ignores the entity types that do not; we just assume + /// the access is not on one of those entity types. + #[cfg(feature = "entity-tags")] + fn tag_types<'s>(&'s self, kind: &EntityRecordKind) -> Result, ()> { + use crate::schema::ValidatorEntityType; + match kind { + EntityRecordKind::Entity(lub) => Ok(lub + .iter() + .filter_map(|ety| { + self.schema + .get_entity_type(ety) + .and_then(ValidatorEntityType::tag_type) + }) + .collect()), + EntityRecordKind::AnyEntity => Ok(self + .schema + .entity_types() + .filter_map(|(_, vety)| vety.tag_type()) + .collect()), + EntityRecordKind::ActionEntity { .. } => Ok(HashSet::new()), // currently, action entities cannot be declared with tags in the schema + EntityRecordKind::Record { .. } => Err(()), + } + } + /// Handles typechecking of `in` expressions. This is complicated because it /// requires searching the schema to determine if an `in` expression /// consisting of variables and literals can ever be true. When we find that diff --git a/cedar-policy-validator/src/typecheck/test.rs b/cedar-policy-validator/src/typecheck/test.rs index c3f0f0b545..813a73e688 100644 --- a/cedar-policy-validator/src/typecheck/test.rs +++ b/cedar-policy-validator/src/typecheck/test.rs @@ -30,5 +30,6 @@ mod optional_attributes; mod partial; mod policy; mod strict; +mod tags; mod type_annotation; mod unspecified_entity; diff --git a/cedar-policy-validator/src/typecheck/test/expr.rs b/cedar-policy-validator/src/typecheck/test/expr.rs index 431794afb8..ea49baf49e 100644 --- a/cedar-policy-validator/src/typecheck/test/expr.rs +++ b/cedar-policy-validator/src/typecheck/test/expr.rs @@ -27,16 +27,17 @@ use smol_str::SmolStr; use crate::{ diagnostics::ValidationError, json_schema, - types::{AttributeType, Type}, + types::Type, validation_errors::{AttributeAccess, LubContext, LubHelp, UnexpectedTypeHelp}, RawName, ValidationMode, }; use super::test_utils::{ - assert_typecheck_fails, assert_typecheck_fails_empty_schema, - assert_typecheck_fails_empty_schema_without_type, assert_typecheck_fails_for_mode, - assert_typechecks, assert_typechecks_empty_schema, assert_typechecks_empty_schema_permissive, - assert_typechecks_for_mode, empty_schema_file, expr_id_placeholder, get_loc, + assert_exactly_one_diagnostic, assert_sets_equal, assert_typecheck_fails, + assert_typecheck_fails_empty_schema, assert_typecheck_fails_empty_schema_without_type, + assert_typecheck_fails_for_mode, assert_typechecks, assert_typechecks_empty_schema, + assert_typechecks_empty_schema_permissive, assert_typechecks_for_mode, empty_schema_file, + expr_id_placeholder, get_loc, }; #[test] @@ -60,7 +61,8 @@ fn slot_typechecks() { fn slot_in_typechecks() { let etype = json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }; let schema = json_schema::NamespaceDefinition::new([("typename".parse().unwrap(), etype)], []); assert_typechecks_for_mode( @@ -89,7 +91,8 @@ fn slot_in_typechecks() { fn slot_equals_typechecks() { let etype = json_schema::EntityType { member_of_types: vec![], - shape: json_schema::EntityAttributes::default(), + shape: json_schema::AttributesOrContext::default(), + tags: None, }; // These don't typecheck in strict mode because the test_util expression // typechecker doesn't have access to a schema, so it can't link @@ -141,15 +144,17 @@ fn set_typechecks() { #[test] fn heterogeneous_set() { let src = "[true, 1]"; - assert_typecheck_fails_empty_schema_without_type( - src.parse().unwrap(), - [ValidationError::incompatible_types( + let errors = assert_typecheck_fails_empty_schema_without_type(src.parse().unwrap()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::incompatible_types( get_loc(src, src), expr_id_placeholder(), [Type::singleton_boolean(true), Type::primitive_long()], LubHelp::None, LubContext::Set, - )], + ) ); } @@ -172,55 +177,63 @@ fn and_typechecks() { #[test] fn and_typecheck_fails() { let src = "1 && true"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "1"), expr_id_placeholder(), Type::primitive_boolean(), Type::primitive_long(), None, - )], + ) ); let src = "(1 > 0) && 2"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "2"), expr_id_placeholder(), Type::primitive_boolean(), Type::primitive_long(), None, - )], + ) ); let src = "(1 > false) && true"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "false"), expr_id_placeholder(), Type::primitive_long(), Type::singleton_boolean(false), None, - )], + ) ); let src = "true && (1 > false)"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "false"), expr_id_placeholder(), Type::primitive_long(), Type::singleton_boolean(false), None, - )], + ) ); } @@ -251,16 +264,18 @@ fn or_left_true_ignores_right() { #[test] fn or_right_true_fails_left() { let src = "1 || true"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "1"), expr_id_placeholder(), Type::primitive_boolean(), Type::primitive_long(), None, - )], + ) ); } @@ -302,68 +317,78 @@ fn or_false() { #[test] fn or_typecheck_fails() { let src = "1 || true"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "1"), expr_id_placeholder(), Type::primitive_boolean(), Type::primitive_long(), None, - )], + ) ); let src = "(2 > 0) || 1"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "1"), expr_id_placeholder(), Type::primitive_boolean(), Type::primitive_long(), None, - )], + ) ); let src = "(1 > true) || false"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "true"), expr_id_placeholder(), Type::primitive_long(), Type::singleton_boolean(true), None, - )], + ) ); let src = "(1 > false) || true"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::singleton_boolean(true), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::singleton_boolean(true)); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "false"), expr_id_placeholder(), Type::primitive_long(), Type::singleton_boolean(false), None, - )], + ) ); let src = "false || (1 > true)"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "true"), expr_id_placeholder(), Type::primitive_long(), Type::singleton_boolean(true), None, - )], + ) ); } @@ -613,16 +638,18 @@ fn record_lub_has_typechecks_permissive() { #[test] fn has_typecheck_fails() { let src = "true has attr"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_one_of_types( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_one_of_types( get_loc(src, "true"), expr_id_placeholder(), [Type::any_entity_reference(), Type::any_record()], Type::singleton_boolean(true), None, - )], + ) ); } @@ -638,42 +665,50 @@ fn record_get_attr_typechecks() { #[test] fn record_get_attr_incompatible() { let src = "(if (1 > 0) then {foo: true} else {foo: 1}).foo"; - assert_typecheck_fails_for_mode( + let errors = assert_typecheck_fails_for_mode( empty_schema_file(), src.parse().unwrap(), None, - [ValidationError::unsafe_attribute_access( + crate::ValidationMode::Permissive, + ); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc(src, src), expr_id_placeholder(), AttributeAccess::Other(vec!["foo".into()]), None, true, - )], - crate::ValidationMode::Permissive, + ) ); } #[test] fn record_get_attr_typecheck_fails() { let src = "2.foo"; - assert_typecheck_fails_empty_schema_without_type( - src.parse().unwrap(), - [ValidationError::expected_one_of_types( + let errors = assert_typecheck_fails_empty_schema_without_type(src.parse().unwrap()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_one_of_types( get_loc(src, "2"), expr_id_placeholder(), [Type::any_entity_reference(), Type::any_record()], Type::primitive_long(), None, - )], + ) ); } #[test] fn record_get_attr_lub_typecheck_fails() { let src = "(if (0 < 1) then {foo: true} else 1).foo"; - assert_typecheck_fails_empty_schema_without_type( - src.parse().unwrap(), - [ValidationError::incompatible_types( + let errors = assert_typecheck_fails_empty_schema_without_type(src.parse().unwrap()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::incompatible_types( get_loc(src, "if (0 < 1) then {foo: true} else 1"), expr_id_placeholder(), [ @@ -685,37 +720,41 @@ fn record_get_attr_lub_typecheck_fails() { ], LubHelp::None, LubContext::Conditional, - )], + ) ); } #[test] fn record_get_attr_does_not_exist() { let src = "{}.foo"; - assert_typecheck_fails_empty_schema_without_type( - src.parse().unwrap(), - [ValidationError::unsafe_attribute_access( + let errors = assert_typecheck_fails_empty_schema_without_type(src.parse().unwrap()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc(src, src), expr_id_placeholder(), AttributeAccess::Other(vec!["foo".into()]), None, false, - )], + ) ); } #[test] fn record_get_attr_lub_does_not_exist() { let src = "(if true then {} else {foo: 1}).foo"; - assert_typecheck_fails_empty_schema_without_type( - src.parse().unwrap(), - [ValidationError::unsafe_attribute_access( + let errors = assert_typecheck_fails_empty_schema_without_type(src.parse().unwrap()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc(src, src), expr_id_placeholder(), AttributeAccess::Other(vec!["foo".into()]), None, false, - )], + ) ); } @@ -760,9 +799,10 @@ fn in_set_typechecks_strict() { #[test] fn in_typecheck_fails() { let src = "0 in true"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + assert_sets_equal( + errors, [ ValidationError::expected_type( get_loc(src, "0"), @@ -795,37 +835,44 @@ fn contains_typechecks() { #[test] fn contains_typecheck_fails() { + use crate::types::AttributeType; let src = r#""foo".contains("bar")"#; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, r#""foo""#), expr_id_placeholder(), Type::any_set(), Type::primitive_string(), Some(UnexpectedTypeHelp::TryUsingLike), - )], + ) ); let src = r#"1.contains("bar")"#; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "1"), expr_id_placeholder(), Type::any_set(), Type::primitive_long(), None, - )], + ) ); let src = r#"{foo: 1}.contains("foo")"#; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "{foo: 1}"), expr_id_placeholder(), Type::any_set(), @@ -834,7 +881,7 @@ fn contains_typecheck_fails() { AttributeType::new(Type::primitive_long(), true), )]), Some(UnexpectedTypeHelp::TryUsingHas), - )], + ) ); } @@ -878,9 +925,10 @@ fn contains_all_typechecks() { #[test] fn contains_all_typecheck_fails() { let src = "1.containsAll(true)"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + assert_sets_equal( + errors, [ ValidationError::expected_type( get_loc(src, "1"), @@ -947,16 +995,18 @@ fn like_typechecks() { #[test] fn like_typecheck_fails() { let src = r#"1 like "bar""#; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "1"), expr_id_placeholder(), Type::primitive_string(), Type::primitive_long(), None, - )], + ) ); } @@ -971,9 +1021,10 @@ fn less_than_typechecks() { #[test] fn less_than_typecheck_fails() { let src = "true < false"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + assert_sets_equal( + errors, [ ValidationError::expected_type( get_loc(src, "true"), @@ -990,7 +1041,7 @@ fn less_than_typecheck_fails() { None, ), ], - ) + ); } #[test] @@ -1002,16 +1053,18 @@ fn not_typechecks() { #[test] fn not_typecheck_fails() { let src = "!1"; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = + assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_boolean()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "1"), expr_id_placeholder(), Type::primitive_boolean(), Type::primitive_long(), None, - )], + ) ); } @@ -1042,23 +1095,26 @@ fn if_false_ignores_then() { #[test] fn if_no_lub_error() { let src = r#"if (1 < 2) then 1 else "test""#; - assert_typecheck_fails_empty_schema_without_type( - src.parse().unwrap(), - [ValidationError::incompatible_types( + let errors = assert_typecheck_fails_empty_schema_without_type(src.parse().unwrap()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::incompatible_types( get_loc(src, src), expr_id_placeholder(), [Type::primitive_long(), Type::primitive_string()], LubHelp::None, LubContext::Conditional, - )], + ) ); } #[test] fn if_typecheck_fails() { let src = r#"if "fail" then 1 else "test""#; - assert_typecheck_fails_empty_schema_without_type( - src.parse().unwrap(), + let errors = assert_typecheck_fails_empty_schema_without_type(src.parse().unwrap()); + assert_sets_equal( + errors, [ ValidationError::incompatible_types( get_loc(src, src), @@ -1087,16 +1143,17 @@ fn neg_typechecks() { #[test] fn neg_typecheck_fails() { let src = r#"-"foo""#; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_long(), - [ValidationError::expected_type( + let errors = assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_long()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, r#""foo""#), expr_id_placeholder(), Type::primitive_long(), Type::primitive_string(), None, - )], + ) ) } @@ -1109,16 +1166,17 @@ fn mul_typechecks() { #[test] fn mul_typecheck_fails() { let src = r#""foo" * 2"#; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_long(), - [ValidationError::expected_type( + let errors = assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_long()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, r#""foo""#), expr_id_placeholder(), Type::primitive_long(), Type::primitive_string(), None, - )], + ) ) } @@ -1133,29 +1191,31 @@ fn add_sub_typechecks() { #[test] fn add_sub_typecheck_fails() { let src = r#"1 + "foo""#; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_long(), - [ValidationError::expected_type( + let errors = assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_long()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, r#""foo""#), expr_id_placeholder(), Type::primitive_long(), Type::primitive_string(), Some(UnexpectedTypeHelp::ConcatenationNotSupported), - )], + ) ); let src = r#""bar" - 2"#; - assert_typecheck_fails_empty_schema( - src.parse().unwrap(), - Type::primitive_long(), - [ValidationError::expected_type( + let errors = assert_typecheck_fails_empty_schema(src.parse().unwrap(), Type::primitive_long()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, r#""bar""#), expr_id_placeholder(), Type::primitive_long(), Type::primitive_string(), None, - )], + ) ); } @@ -1164,17 +1224,21 @@ fn is_typecheck_fails() { let schema: json_schema::NamespaceDefinition = serde_json::from_value(json!({ "entityTypes": { "User": {}, }, "actions": {} })).unwrap(); let src = r#"1 is User"#; - assert_typecheck_fails( + let errors = assert_typecheck_fails( schema, src.parse().unwrap(), Some(Type::primitive_boolean()), - [ValidationError::expected_type( + ); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "1"), expr_id_placeholder(), Type::any_entity_reference(), Type::primitive_long(), Some(UnexpectedTypeHelp::TypeTestNotSupported), - )], + ) ); } diff --git a/cedar-policy-validator/src/typecheck/test/extensions.rs b/cedar-policy-validator/src/typecheck/test/extensions.rs index 713d1dc1a1..306ea3f1db 100644 --- a/cedar-policy-validator/src/typecheck/test/extensions.rs +++ b/cedar-policy-validator/src/typecheck/test/extensions.rs @@ -22,8 +22,8 @@ use cedar_policy_core::ast::Expr; use std::str::FromStr; use super::test_utils::{ - assert_typecheck_fails_empty_schema, assert_typechecks_empty_schema, expr_id_placeholder, - get_loc, + assert_exactly_one_diagnostic, assert_typecheck_fails_empty_schema, + assert_typechecks_empty_schema, expr_id_placeholder, get_loc, }; #[test] @@ -46,55 +46,56 @@ fn ip_extension_typechecks() { fn ip_extension_typecheck_fails() { use cedar_policy_core::ast::Name; + use crate::typecheck::test::test_utils::assert_exactly_one_diagnostic; + let ipaddr_name = Name::parse_unqualified_name("ipaddr").expect("should be a valid identifier"); let src = "ip(3)"; let expr = Expr::from_str(src).expect("parsing should succeed"); - assert_typecheck_fails_empty_schema( - expr, - Type::extension(ipaddr_name.clone()), - [ValidationError::expected_type( + let errors = assert_typecheck_fails_empty_schema(expr, Type::extension(ipaddr_name.clone())); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::expected_type( get_loc(src, "3"), expr_id_placeholder(), Type::primitive_string(), Type::primitive_long(), None, - )], + ) ); let src = "ip(\"foo\")"; let expr = Expr::from_str(src).expect("parsing should succeed"); - assert_typecheck_fails_empty_schema( - expr, - Type::extension(ipaddr_name.clone()), - [ValidationError::function_argument_validation( + let errors = assert_typecheck_fails_empty_schema(expr, Type::extension(ipaddr_name.clone())); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::function_argument_validation( get_loc(src, src), expr_id_placeholder(), "Failed to parse as IP address: `\"foo\"`".into(), - )], + ) ); let src = "ip(\"127.0.0.1\").isIpv4(3)"; let expr = Expr::from_str(src).expect("parsing should succeed"); - assert_typecheck_fails_empty_schema( - expr.clone(), - Type::primitive_boolean(), - [ValidationError::wrong_number_args( - get_loc(src, src), - expr_id_placeholder(), - 1, - 2, - )], + let errors = assert_typecheck_fails_empty_schema(expr.clone(), Type::primitive_boolean()); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::wrong_number_args(get_loc(src, src), expr_id_placeholder(), 1, 2,) ); let src = "ip(\"127.0.0.1\").isInRange(3)"; let expr = Expr::from_str(src).expect("parsing should succeed"); - assert_typecheck_fails_empty_schema( - expr, - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = assert_typecheck_fails_empty_schema(expr, Type::primitive_boolean()); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::expected_type( get_loc(src, "3"), expr_id_placeholder(), Type::extension(ipaddr_name), Type::primitive_long(), None, - )], + ) ); } @@ -130,51 +131,51 @@ fn decimal_extension_typecheck_fails() { Name::parse_unqualified_name("decimal").expect("should be a valid identifier"); let src = "decimal(3)"; let expr = Expr::from_str(src).expect("parsing should succeed"); - assert_typecheck_fails_empty_schema( - expr, - Type::extension(decimal_name.clone()), - [ValidationError::expected_type( + let errors = assert_typecheck_fails_empty_schema(expr, Type::extension(decimal_name.clone())); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::expected_type( get_loc(src, "3"), expr_id_placeholder(), Type::primitive_string(), Type::primitive_long(), None, - )], + ) ); let src = "decimal(\"foo\")"; let expr = Expr::from_str(src).expect("parsing should succeed"); - assert_typecheck_fails_empty_schema( - expr.clone(), - Type::extension(decimal_name.clone()), - [ValidationError::function_argument_validation( + let errors = + assert_typecheck_fails_empty_schema(expr.clone(), Type::extension(decimal_name.clone())); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::function_argument_validation( get_loc(src, src), expr_id_placeholder(), "Failed to parse as a decimal value: `\"foo\"`".into(), - )], + ) ); let src = "decimal(\"1.23\").lessThan(3, 4)"; let expr = Expr::from_str(src).expect("parsing should succeed"); - assert_typecheck_fails_empty_schema( - expr.clone(), - Type::primitive_boolean(), - [ValidationError::wrong_number_args( - get_loc(src, src), - expr_id_placeholder(), - 2, - 3, - )], + let errors = assert_typecheck_fails_empty_schema(expr.clone(), Type::primitive_boolean()); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::wrong_number_args(get_loc(src, src), expr_id_placeholder(), 2, 3,) ); let src = "decimal(\"1.23\").lessThan(4)"; let expr = Expr::from_str(src).expect("parsing should succeed"); - assert_typecheck_fails_empty_schema( - expr, - Type::primitive_boolean(), - [ValidationError::expected_type( + let errors = assert_typecheck_fails_empty_schema(expr, Type::primitive_boolean()); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::expected_type( get_loc(src, "4"), expr_id_placeholder(), Type::extension(decimal_name), Type::primitive_long(), None, - )], + ) ); } diff --git a/cedar-policy-validator/src/typecheck/test/namespace.rs b/cedar-policy-validator/src/typecheck/test/namespace.rs index 045bf7e686..da53b3c5dc 100644 --- a/cedar-policy-validator/src/typecheck/test/namespace.rs +++ b/cedar-policy-validator/src/typecheck/test/namespace.rs @@ -20,6 +20,7 @@ use cool_asserts::assert_matches; use serde_json::json; +use std::collections::HashSet; use std::str::FromStr; use std::vec; @@ -30,8 +31,9 @@ use cedar_policy_core::{ }; use super::test_utils::{ - assert_policy_typecheck_fails, assert_policy_typecheck_warns, assert_policy_typechecks, - assert_typecheck_fails, assert_typechecks, expr_id_placeholder, get_loc, + assert_exactly_one_diagnostic, assert_policy_typecheck_fails, assert_policy_typecheck_warns, + assert_policy_typechecks, assert_sets_equal, assert_typecheck_fails, assert_typechecks, + expr_id_placeholder, get_loc, }; use crate::{ diagnostics::ValidationError, @@ -70,35 +72,25 @@ fn namespaced_entity_type_schema() -> json_schema::Fragment { .expect("Expected valid schema") } -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_expr_typechecks_namespace_schema(e: Expr, t: Type) { - assert_typechecks(namespaced_entity_type_schema(), e, t) -} - -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_expr_typecheck_fails_namespace_schema( - e: Expr, - t: Option, - errs: impl IntoIterator, -) { - assert_typecheck_fails(namespaced_entity_type_schema(), e, t, errs) -} - #[test] fn namespaced_entity_eq() { - assert_expr_typechecks_namespace_schema( + assert_typechecks( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::Foo::"alice" == N::S::Foo::"alice""#).expect("Expr should parse."), Type::True, ); - assert_expr_typechecks_namespace_schema( + assert_typechecks( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::Foo::"alice" == N::S::Foo::"bob""#).expect("Expr should parse."), Type::False, ); - assert_expr_typechecks_namespace_schema( + assert_typechecks( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::Foo::"alice" == N::S::Bar::"bob""#).expect("Expr should parse."), Type::False, ); - assert_expr_typechecks_namespace_schema( + assert_typechecks( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::Action::"baz" == N::S::Action::"baz""#) .expect("Expr should parse."), Type::True, @@ -107,11 +99,13 @@ fn namespaced_entity_eq() { #[test] fn namespaced_entity_in() { - assert_expr_typechecks_namespace_schema( + assert_typechecks( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::Foo::"alice" in N::S::Foo::"bob""#).expect("Expr should parse."), Type::primitive_boolean(), ); - assert_expr_typechecks_namespace_schema( + assert_typechecks( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::Foo::"alice" in N::S::Bar::"bob""#).expect("Expr should parse."), Type::False, ); @@ -119,11 +113,13 @@ fn namespaced_entity_in() { #[test] fn namespaced_entity_has() { - assert_expr_typechecks_namespace_schema( + assert_typechecks( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::Foo::"alice" has foo"#).expect("Expr should parse."), Type::False, ); - assert_expr_typechecks_namespace_schema( + assert_typechecks( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::Foo::"alice" has name"#).expect("Expr should parse."), Type::primitive_boolean(), ); @@ -131,7 +127,8 @@ fn namespaced_entity_has() { #[test] fn namespaced_entity_get_attr() { - assert_expr_typechecks_namespace_schema( + assert_typechecks( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::Foo::"alice".name"#).expect("Expr should parse."), Type::primitive_string(), ); @@ -140,51 +137,62 @@ fn namespaced_entity_get_attr() { #[test] fn namespaced_entity_can_type_error() { let src = r#"N::S::Foo::"alice" > 1"#; - assert_expr_typecheck_fails_namespace_schema( + let errors = assert_typecheck_fails( + namespaced_entity_type_schema(), Expr::from_str(src).expect("Expr should parse."), Some(Type::primitive_boolean()), - [ValidationError::expected_type( + ); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::expected_type( get_loc(src, r#"N::S::Foo::"alice""#), expr_id_placeholder(), Type::primitive_long(), Type::named_entity_reference_from_str("N::S::Foo"), None, - )], + ) ); } #[test] fn namespaced_entity_wrong_namespace() { - assert_expr_typecheck_fails_namespace_schema( + let errors = assert_typecheck_fails( + namespaced_entity_type_schema(), Expr::from_str(r#"N::S::T::Foo::"alice""#).expect("Expr should parse."), None, - [], ); - assert_expr_typecheck_fails_namespace_schema( + assert_sets_equal(errors, []); + let errors = assert_typecheck_fails( + namespaced_entity_type_schema(), Expr::from_str(r#"N::Foo::"alice""#).expect("Expr should parse."), None, - [], ); - assert_expr_typecheck_fails_namespace_schema( + assert_sets_equal(errors, []); + let errors = assert_typecheck_fails( + namespaced_entity_type_schema(), Expr::from_str(r#"Foo::"alice""#).expect("Expr should parse."), None, - [], ); - assert_expr_typecheck_fails_namespace_schema( + assert_sets_equal(errors, []); + let errors = assert_typecheck_fails( + namespaced_entity_type_schema(), Expr::from_str(r#"N::Action::"baz""#).expect("Expr should parse."), None, - [], ); - assert_expr_typecheck_fails_namespace_schema( + assert_sets_equal(errors, []); + let errors = assert_typecheck_fails( + namespaced_entity_type_schema(), Expr::from_str(r#"Action::N::S::"baz""#).expect("Expr should parse."), None, - [], ); - assert_expr_typecheck_fails_namespace_schema( + assert_sets_equal(errors, []); + let errors = assert_typecheck_fails( + namespaced_entity_type_schema(), Expr::from_str(r#"Action::"baz""#).expect("Expr should parse."), None, - [], ); + assert_sets_equal(errors, []); } #[test] @@ -364,11 +372,11 @@ fn multiple_namespaces_attributes() { Type::named_entity_reference_from_str("B::Foo"), ); let src = "B::Foo::\"foo\".x"; - assert_typecheck_fails( - schema, - Expr::from_str(src).unwrap(), - None, - [ValidationError::unsafe_attribute_access( + let errors = assert_typecheck_fails(schema, Expr::from_str(src).unwrap(), None); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::unsafe_attribute_access( get_loc(src, src), PolicyID::from_string("expr"), AttributeAccess::EntityLUB( @@ -377,7 +385,7 @@ fn multiple_namespaces_attributes() { ), None, false, - )], + ) ); } @@ -487,11 +495,8 @@ fn multiple_namespaces_applies_to() { // Test cases added for namespace bug found by DRT. #[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_policy_typecheck_fails_namespace_schema( - p: StaticPolicy, - expected_type_errors: impl IntoIterator, -) { - assert_policy_typecheck_fails(namespaced_entity_type_schema(), p, expected_type_errors); +fn assert_policy_typecheck_fails_namespace_schema(p: StaticPolicy) -> HashSet { + assert_policy_typecheck_fails(namespaced_entity_type_schema(), p) } #[test] @@ -503,15 +508,17 @@ fn namespaced_entity_is_wrong_type_and() { }; "#; let policy = parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."); - assert_policy_typecheck_fails_namespace_schema( - policy, - [ValidationError::expected_type( + let errors = assert_policy_typecheck_fails_namespace_schema(policy); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::expected_type( get_loc(src, r#"N::S::Foo::"alice""#), PolicyID::from_string("0"), Type::primitive_boolean(), Type::named_entity_reference_from_str("N::S::Foo"), None, - )], + ) ); } @@ -524,15 +531,17 @@ fn namespaced_entity_is_wrong_type_when() { }; "#; let policy = parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."); - assert_policy_typecheck_fails_namespace_schema( - policy, - [ValidationError::expected_type( + let errors = assert_policy_typecheck_fails_namespace_schema(policy); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::expected_type( get_loc(src, r#"N::S::Foo::"alice""#), PolicyID::from_string("0"), Type::primitive_boolean(), Type::named_entity_reference_from_str("N::S::Foo"), None, - )], + ) ); } @@ -577,13 +586,14 @@ fn multi_namespace_action_eq() { r#"permit(principal, action, resource) when { NS1::Action::"B" == NS2::Action::"B" };"#, ) .unwrap(); - assert_policy_typecheck_warns( - schema.clone(), - policy.clone(), - [ValidationWarning::impossible_policy( + let warnings = assert_policy_typecheck_warns(schema.clone(), policy.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy( policy.loc().cloned(), PolicyID::from_string("policy0"), - )], + ) ); } @@ -643,13 +653,14 @@ fn multi_namespace_action_in() { r#"permit(principal, action in NS4::Action::"Group", resource);"#, ) .unwrap(); - assert_policy_typecheck_warns( - schema.clone(), - policy.clone(), - [ValidationWarning::impossible_policy( + let warnings = assert_policy_typecheck_warns(schema.clone(), policy.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy( policy.loc().cloned(), PolicyID::from_string("policy0"), - )], + ) ); } diff --git a/cedar-policy-validator/src/typecheck/test/optional_attributes.rs b/cedar-policy-validator/src/typecheck/test/optional_attributes.rs index ec7cf26d66..a096c16afd 100644 --- a/cedar-policy-validator/src/typecheck/test/optional_attributes.rs +++ b/cedar-policy-validator/src/typecheck/test/optional_attributes.rs @@ -30,7 +30,8 @@ use crate::{ }; use super::test_utils::{ - assert_policy_typecheck_fails, assert_policy_typecheck_warns, assert_policy_typechecks, get_loc, + assert_exactly_one_diagnostic, assert_policy_typecheck_fails, assert_policy_typecheck_warns, + assert_policy_typechecks, assert_sets_equal, get_loc, }; fn schema_with_optionals() -> json_schema::NamespaceDefinition { @@ -62,19 +63,6 @@ fn schema_with_optionals() -> json_schema::NamespaceDefinition { .expect("Expected valid schema.") } -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_policy_typechecks_optional_schema(p: StaticPolicy) { - assert_policy_typechecks(schema_with_optionals(), p); -} - -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_policy_typecheck_fails_optional_schema( - p: StaticPolicy, - expected_type_errors: impl IntoIterator, -) { - assert_policy_typecheck_fails(schema_with_optionals(), p, expected_type_errors); -} - #[test] fn simple_and_guard_principal() { let policy = parse_policy( @@ -82,7 +70,7 @@ fn simple_and_guard_principal() { r#"permit(principal, action, resource) when { principal has name && principal.name == "foo" };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -92,7 +80,7 @@ fn simple_and_guard_resource() { r#"permit(principal, action, resource) when { resource has name && resource.name == "foo" };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -102,7 +90,7 @@ fn principal_and_resource_in_capability() { r#"permit(principal, action, resource) when { resource has name && principal has age && resource.name == "foo" && principal.age == 1};"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -112,7 +100,7 @@ fn and_branches_union() { r#"permit(principal, action, resource) when { (principal has name && principal has age) && principal.name == "foo" };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -122,7 +110,7 @@ fn and_rhs_true_has_lhs_capability() { r#"permit(principal, action, resource) when { (principal has name && true) && principal.name == "foo" };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -132,7 +120,7 @@ fn and_lhs_true_has_rhs_capability() { r#"permit(principal, action, resource) when { (true && principal has name) && principal.name == "foo" };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -142,7 +130,7 @@ fn and_branches_use_prior_capability() { r#"permit(principal, action, resource) when { (principal has name) && (principal.name == "foo" && principal.name == "foo") };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -152,7 +140,7 @@ fn and_short_circuit_without_error() { r#"permit(principal, action, resource) when { principal has age || (false && principal.name == "foo") };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -162,7 +150,7 @@ fn or_branches_intersect() { r#"permit(principal, action, resource) when { (principal has name || principal has name) && principal.name == "foo" };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -172,7 +160,7 @@ fn or_lhs_false_has_rhs_capability() { r#"permit(principal, action, resource) when { (false || principal has name) && principal.name == "foo" };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -182,7 +170,7 @@ fn or_rhs_false_has_lhs_capability() { r#"permit(principal, action, resource) when { (principal has name || false) && principal.name == "foo" };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -192,7 +180,7 @@ fn or_branches_use_prior_capability() { r#"permit(principal, action, resource) when { (principal has name) && (principal.name == "foo" || principal.name == "foo") };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -202,7 +190,7 @@ fn then_guarded_access_by_test() { r#"permit(principal, action, resource) when { if principal has name then principal.name == "foo" else false };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -212,7 +200,7 @@ fn then_guarded_access_by_prior_capability() { r#"permit(principal, action, resource) when { principal has name && (if principal has age then principal.name == "foo" else false) };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -222,7 +210,7 @@ fn else_guarded_access_by_prior_capability() { r#"permit(principal, action, resource) when { principal has name && (if principal has age then false else principal.name == "foo") };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -232,7 +220,7 @@ fn if_true_short_circuit_without_error() { r#"permit(principal, action, resource) when { principal has name && (if true then principal.name == "foo" else principal.age == 1)};"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -242,7 +230,7 @@ fn if_false_short_circuit_without_error() { r#"permit(principal, action, resource) when { principal has name && (if false then principal.age == 1 else principal.name == "foo")};"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -261,7 +249,7 @@ fn if_then_else_then_else_same() { };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -280,7 +268,7 @@ fn if_then_else_can_use_guard_capability() { };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -298,7 +286,7 @@ fn if_then_else_guard_union_then_equal_else() { };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[test] @@ -312,7 +300,7 @@ fn guarded_has_true_short_circuits() { };"#, ) .expect("Policy should parse."); - assert_policy_typechecks_optional_schema(policy); + assert_policy_typechecks(schema_with_optionals(), policy); } #[track_caller] // report the caller's location as the location of the panic, not the location in this function @@ -320,16 +308,18 @@ fn assert_name_access_fails(policy: StaticPolicy) { let id = policy.id().clone(); let loc = get_loc(policy.loc().unwrap().src.clone(), "principal.name"); - assert_policy_typecheck_fails_optional_schema( - policy, - [ValidationError::unsafe_optional_attribute_access( + let errors = assert_policy_typecheck_fails(schema_with_optionals(), policy); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::unsafe_optional_attribute_access( loc, id, AttributeAccess::EntityLUB( EntityLUB::single_entity("User".parse().unwrap()), vec!["name".into()], ), - )], + ) ); } @@ -583,33 +573,35 @@ fn record_optional_attrs() { let src = r#"permit(principal, action, resource) when { principal.record has other && principal.record.name == "foo" };"#; let failing_policy = parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."); - assert_policy_typecheck_fails( - schema.clone(), - failing_policy, - [ValidationError::unsafe_optional_attribute_access( + let errors = assert_policy_typecheck_fails(schema.clone(), failing_policy); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::unsafe_optional_attribute_access( get_loc(src, "principal.record.name"), PolicyID::from_string("0"), AttributeAccess::EntityLUB( EntityLUB::single_entity("User".parse().unwrap()), vec!["name".into(), "record".into()], ), - )], + ) ); let src = r#"permit(principal, action, resource) when { principal.record has name && principal.name == "foo" };"#; let failing_policy2 = parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."); - assert_policy_typecheck_fails( - schema, - failing_policy2, - [ValidationError::unsafe_optional_attribute_access( + let errors = assert_policy_typecheck_fails(schema, failing_policy2); + let type_error = assert_exactly_one_diagnostic(errors); + assert_eq!( + type_error, + ValidationError::unsafe_optional_attribute_access( get_loc(src, "principal.name"), PolicyID::from_string("0"), AttributeAccess::EntityLUB( EntityLUB::single_entity("User".parse().unwrap()), vec!["name".into()], ), - )], + ) ); } @@ -762,16 +754,17 @@ fn action_attrs_failing() { let src = r#"permit(principal, action == Action::"view", resource) when { action.canUndo };"#; let failing_policy = parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."); - assert_policy_typecheck_fails( - schema.clone(), - failing_policy, - [ValidationError::unsafe_attribute_access( + let errors = assert_policy_typecheck_fails(schema.clone(), failing_policy); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc(src, "action.canUndo"), PolicyID::from_string("0"), AttributeAccess::Other(vec!["canUndo".into()]), Some("isReadOnly".to_string()), false, - )], + ) ); // No error is returned, but the typechecker identifies that `action has ""` @@ -781,13 +774,14 @@ fn action_attrs_failing() { r#"permit(principal, action == Action::"view", resource) when { action has "" };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns( - schema.clone(), - failing_policy.clone(), - [ValidationWarning::impossible_policy( + let warnings = assert_policy_typecheck_warns(schema.clone(), failing_policy.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy( failing_policy.loc().cloned(), PolicyID::from_string("0"), - )], + ) ); // Fails because OtherNamespace::Action::"view" is not defined in the schema. @@ -806,5 +800,6 @@ fn action_attrs_failing() { "#, ) .expect("Policy should parse."); - assert_policy_typecheck_fails(schema, failing_policy, []); + let errors = assert_policy_typecheck_fails(schema, failing_policy); + assert_sets_equal(errors, []); } diff --git a/cedar-policy-validator/src/typecheck/test/partial.rs b/cedar-policy-validator/src/typecheck/test/partial.rs index bd92034102..006be88071 100644 --- a/cedar-policy-validator/src/typecheck/test/partial.rs +++ b/cedar-policy-validator/src/typecheck/test/partial.rs @@ -22,9 +22,7 @@ use std::collections::HashSet; use cedar_policy_core::ast::{PolicyID, StaticPolicy, Template}; use cedar_policy_core::parser::parse_policy; -use super::test_utils::{ - assert_expected_type_errors, assert_expected_warnings, empty_schema_file, get_loc, -}; +use super::test_utils::{assert_sets_equal, empty_schema_file, get_loc}; use crate::json_schema; use crate::typecheck::Typechecker; use crate::types::{EntityLUB, Type}; @@ -38,14 +36,14 @@ pub(crate) fn assert_partial_typecheck( ) { let schema = schema.try_into().expect("Failed to construct schema."); let typechecker = Typechecker::new(&schema, ValidationMode::Partial, policy.id().clone()); - let mut type_errors: HashSet = HashSet::new(); + let mut errors: HashSet = HashSet::new(); let mut warnings: HashSet = HashSet::new(); let typechecked = typechecker.typecheck_policy( &Template::link_static_policy(policy.clone()).0, - &mut type_errors, + &mut errors, &mut warnings, ); - assert_eq!(type_errors, HashSet::new(), "Did not expect any errors."); + assert_eq!(errors, HashSet::new(), "Did not expect any errors."); assert!(typechecked, "Expected that policy would typecheck."); } @@ -53,18 +51,18 @@ pub(crate) fn assert_partial_typecheck( pub(crate) fn assert_partial_typecheck_fails( schema: impl TryInto, policy: StaticPolicy, - expected_type_errors: impl IntoIterator, + expected_errors: impl IntoIterator, ) { let schema = schema.try_into().expect("Failed to construct schema."); let typechecker = Typechecker::new(&schema, ValidationMode::Partial, policy.id().clone()); - let mut type_errors: HashSet = HashSet::new(); + let mut errors: HashSet = HashSet::new(); let mut warnings: HashSet = HashSet::new(); let typechecked = typechecker.typecheck_policy( &Template::link_static_policy(policy.clone()).0, - &mut type_errors, + &mut errors, &mut warnings, ); - assert_expected_type_errors(expected_type_errors, &type_errors); + assert_sets_equal(expected_errors, errors); assert!(!typechecked, "Expected that policy would not typecheck."); } @@ -76,14 +74,14 @@ pub(crate) fn assert_partial_typecheck_warns( ) { let schema = schema.try_into().expect("Failed to construct schema."); let typechecker = Typechecker::new(&schema, ValidationMode::Partial, policy.id().clone()); - let mut type_errors: HashSet = HashSet::new(); + let mut errors: HashSet = HashSet::new(); let mut warnings: HashSet = HashSet::new(); let typechecked = typechecker.typecheck_policy( &Template::link_static_policy(policy.clone()).0, - &mut type_errors, + &mut errors, &mut warnings, ); - assert_expected_warnings(expected_warnings, &warnings); + assert_sets_equal(warnings, expected_warnings); assert!( typechecked, "Expected that policy would typecheck (with warnings)." diff --git a/cedar-policy-validator/src/typecheck/test/policy.rs b/cedar-policy-validator/src/typecheck/test/policy.rs index 40ca711325..9111574ab3 100644 --- a/cedar-policy-validator/src/typecheck/test/policy.rs +++ b/cedar-policy-validator/src/typecheck/test/policy.rs @@ -26,9 +26,10 @@ use cedar_policy_core::{ }; use super::test_utils::{ - assert_policy_typecheck_fails, assert_policy_typecheck_fails_for_mode, - assert_policy_typecheck_warns, assert_policy_typecheck_warns_for_mode, - assert_policy_typechecks, assert_policy_typechecks_for_mode, assert_typechecks, get_loc, + assert_exactly_one_diagnostic, assert_policy_typecheck_fails, + assert_policy_typecheck_fails_for_mode, assert_policy_typecheck_warns, + assert_policy_typecheck_warns_for_mode, assert_policy_typechecks, + assert_policy_typechecks_for_mode, assert_typechecks, get_loc, }; use crate::{ diagnostics::ValidationError, @@ -106,66 +107,15 @@ fn simple_schema_file() -> json_schema::NamespaceDefinition { .expect("Expected valid schema") } -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_typechecks_simple_schema(expr: Expr, expected: Type) { - assert_typechecks(simple_schema_file(), expr, expected) -} - -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_policy_typechecks_simple_schema(p: impl Into>) { - assert_policy_typechecks(simple_schema_file(), p) -} - #[track_caller] // report the caller's location as the location of the panic, not the location in this function fn assert_policy_typechecks_permissive_simple_schema(p: impl Into>) { assert_policy_typechecks_for_mode(simple_schema_file(), p, ValidationMode::Permissive) } -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_policy_typecheck_fails_simple_schema( - p: impl Into>, - expected_type_errors: impl IntoIterator, -) { - assert_policy_typecheck_fails(simple_schema_file(), p, expected_type_errors) -} - -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_policy_typecheck_warns_simple_schema( - p: impl Into>, - expected_warnings: impl IntoIterator, -) { - assert_policy_typecheck_warns(simple_schema_file(), p, expected_warnings) -} - -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_policy_typecheck_permissive_fails_simple_schema( - p: impl Into>, - expected_type_errors: impl IntoIterator, -) { - assert_policy_typecheck_fails_for_mode( - simple_schema_file(), - p, - expected_type_errors, - ValidationMode::Permissive, - ) -} - -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -fn assert_policy_typecheck_permissive_warns_simple_schema( - p: impl Into>, - expected_warnings: impl IntoIterator, -) { - assert_policy_typecheck_warns_for_mode( - simple_schema_file(), - p, - expected_warnings, - ValidationMode::Permissive, - ) -} - #[test] fn entity_literal_typechecks() { - assert_typechecks_simple_schema( + assert_typechecks( + simple_schema_file(), Expr::val( EntityUID::with_eid_and_type("Group", "friends") .expect("EUID component failed to parse."), @@ -237,7 +187,7 @@ fn policy_checked_in_multiple_envs() { #[test] fn policy_single_action_attribute_access() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action == Action::"view_photo", resource) when { resource.file_type == "jpg" };"# ).expect("Policy should parse.")); @@ -248,7 +198,7 @@ fn policy_principal_action_attribute_access() { // The principal for Action::"view_photo" is ordinarily User or Group, but the // principal condition refines this to User, so we can access the age // attribute. - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal == User::"alice", action == Action::"view_photo", resource) when { principal.age > 21 };"# ).expect("Policy should parse.")); @@ -257,7 +207,7 @@ fn policy_principal_action_attribute_access() { #[test] fn policy_action_multiple_principal_attribute_access() { // The attribute name is defined for User and Group. - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action == Action::"view_photo", resource) when { principal.name == "alice" };"# ).expect("Policy should parse.")); @@ -267,7 +217,8 @@ fn policy_action_multiple_principal_attribute_access() { fn policy_no_conditions_attribute_access() { // Both actions in the schema apply to principals with the attribute name, // so the action condition isn't required either. - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource) when { principal.name == "alice" };"#, @@ -281,7 +232,7 @@ fn policy_resource_narrows_principal() { // The resource condition doesn't match the resource applies_to set for // "view_photo", so we know the action is "delete_group", which only // accepts User as the principal. - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource == Group::"jane_friends") when { principal.age > 22};"# ).expect("Policy should parse.")); @@ -289,7 +240,7 @@ fn policy_resource_narrows_principal() { #[test] fn policy_action_in() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action in [Action::"delete_group", Action::"view_photo"], resource in Album::"vacation_photos") when { resource.file_type == "png" };"# ).expect("Policy should parse.")); @@ -298,9 +249,14 @@ fn policy_action_in() { #[test] fn policy_invalid_attribute() { let src = r#"permit(principal, action in [Action::"delete_group", Action::"view_photo"], resource) when { resource.file_type == "jpg" };"#; - assert_policy_typecheck_fails_simple_schema( + let errors = assert_policy_typecheck_fails( + simple_schema_file(), parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."), - [ValidationError::unsafe_attribute_access( + ); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc(src, "resource.file_type"), PolicyID::from_string("0"), AttributeAccess::EntityLUB( @@ -309,16 +265,21 @@ fn policy_invalid_attribute() { ), Some("name".into()), false, - )], + ) ); } #[test] fn policy_invalid_attribute_2() { let src = r#"permit(principal, action == Action::"view_photo", resource) when { principal.age > 21 };"#; - assert_policy_typecheck_fails_simple_schema( + let errors = assert_policy_typecheck_fails( + simple_schema_file(), parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."), - [ValidationError::unsafe_attribute_access( + ); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc(src, "principal.age"), PolicyID::from_string("0"), AttributeAccess::EntityLUB( @@ -327,7 +288,7 @@ fn policy_invalid_attribute_2() { ), Some("name".into()), false, - )], + ) ); } @@ -335,9 +296,14 @@ fn policy_invalid_attribute_2() { fn policy_context_invalid_attribute() { let src = r#"permit(principal, action == Action::"view_photo", resource) when { context.fake };"#; - assert_policy_typecheck_fails_simple_schema( + let errors = assert_policy_typecheck_fails( + simple_schema_file(), parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."), - [ValidationError::unsafe_attribute_access( + ); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc(src, "context.fake"), PolicyID::from_string("0"), AttributeAccess::Context( @@ -346,13 +312,13 @@ fn policy_context_invalid_attribute() { ), None, false, - )], + ) ); } #[test] fn policy_entity_type_attr() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action == Action::"view_photo", resource) when { resource.owner.age > 0 };"# ).expect("Policy should parse.")); @@ -360,7 +326,7 @@ fn policy_entity_type_attr() { #[test] fn policy_entity_type_action_in() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action in Action::"view_photo", resource) when { resource.owner.age > 0 };"# ).expect("Policy should parse.")); @@ -368,7 +334,7 @@ fn policy_entity_type_action_in() { #[test] fn policy_entity_type_action_in_body() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource) when { action in Action::"view_photo" && resource.owner.age > 0 };"# ).expect("Policy should parse.")); @@ -376,7 +342,7 @@ fn policy_entity_type_action_in_body() { #[test] fn policy_entity_type_action_in_set() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action in [Action::"view_photo"], resource) when { resource.owner.age > 0 };"# ).expect("Policy should parse.")); @@ -384,15 +350,15 @@ fn policy_entity_type_action_in_set() { #[test] fn policy_entity_type_principal_in_set() { - assert_policy_typechecks_permissive_simple_schema(parse_policy( + assert_policy_typechecks_for_mode(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource) when { principal in [User::"admin", Group::"admin"] || true};"# - ).expect("Policy should parse.")); + ).expect("Policy should parse."), ValidationMode::Permissive); } #[test] fn policy_entity_type_principal_in_set_user_only() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource) when { principal in [User::"admin"] && principal.age == 0};"# ).expect("Policy should parse.")); @@ -400,7 +366,7 @@ fn policy_entity_type_principal_in_set_user_only() { #[test] fn policy_lub_entity_type_attr() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action == Action::"view_photo", resource) when { resource.owner.favorite.file_type == "png" };"# ).expect("Policy should parse.")); @@ -413,12 +379,11 @@ fn policy_impossible_scope() { r#"permit(principal == Group::"foo", action == Action::"delete_group", resource);"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); } @@ -429,12 +394,11 @@ fn policy_impossible_literal_euids() { r#"permit(principal, action, resource) when { Group::"foo" in User::"bar" };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); } @@ -445,18 +409,17 @@ fn policy_impossible_not_has() { r#"permit(principal, action, resource) when { ! ({name: "alice"} has name)};"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); } #[test] fn policy_if_entities_lub() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource) when { (if principal.name == "foo" then User::"alice" else User::"bob").age > 21};"# ).expect("Policy should parse.")); @@ -469,12 +432,11 @@ fn policy_in_action_impossible() { r#"permit(principal, action, resource) when { User::"alice" in [action] };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); let p = parse_policy( @@ -482,12 +444,11 @@ fn policy_in_action_impossible() { r#"permit(principal, action, resource) when { User::"alice" in [Action::"view_photo"] };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); let p = parse_policy( @@ -495,12 +456,11 @@ fn policy_in_action_impossible() { r#"permit(principal, action, resource) when { principal in [action] };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); let p = parse_policy( @@ -508,12 +468,11 @@ fn policy_in_action_impossible() { r#"permit(principal, action, resource) when { principal in action };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); let p = parse_policy( @@ -521,12 +480,11 @@ fn policy_in_action_impossible() { r#"permit(principal, action, resource) when { principal in Action::"view_photo" };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); let p = parse_policy( @@ -534,12 +492,11 @@ fn policy_in_action_impossible() { r#"permit(principal, action, resource) when { principal in [Action::"view_photo", Action::"delete_group"] };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); let p = parse_policy( @@ -547,12 +504,15 @@ fn policy_in_action_impossible() { r#"permit(principal, action, resource) when { principal in [Action::"view_photo", Photo::"bar"] };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_permissive_warns_simple_schema( + let warnings = assert_policy_typecheck_warns_for_mode( + simple_schema_file(), p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + ValidationMode::Permissive, + ); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); } @@ -563,18 +523,17 @@ fn policy_action_in_impossible() { r#"permit(principal, action, resource) when { action in [User::"alice"] };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); } #[test] fn policy_entity_has_then_get() { - assert_policy_typechecks_simple_schema(parse_policy( + assert_policy_typechecks(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource) when { principal has age && principal.age > 0};"#, ).expect("Policy should parse.")); @@ -582,35 +541,38 @@ fn policy_entity_has_then_get() { #[test] fn policy_entity_top_has() { - assert_policy_typechecks_permissive_simple_schema(parse_policy( + assert_policy_typechecks_for_mode(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource) when { (if principal.name == "foo" then principal else resource) has name || true };"#, - ).expect("Policy should parse.")); + ).expect("Policy should parse."), ValidationMode::Permissive); } #[test] fn entity_lub_access_attribute() { - assert_policy_typechecks_permissive_simple_schema(parse_policy( + assert_policy_typechecks_for_mode(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource) when { (if 1 > 0 then User::"alice" else Group::"alice_friends").name like "foo"};"# - ).expect("Policy should parse.")); + ).expect("Policy should parse."), ValidationMode::Permissive); } #[test] fn entity_lub_no_common_attributes_is_entity() { - assert_policy_typechecks_permissive_simple_schema(parse_policy( + assert_policy_typechecks_for_mode(simple_schema_file(), parse_policy( Some(PolicyID::from_string("0")), r#"permit(principal, action, resource) when { principal in (if 1 > 0 then User::"alice" else Photo::"vacation.jpg")};"# - ).expect("Policy should parse.")); + ).expect("Policy should parse."), ValidationMode::Permissive); } #[test] fn entity_lub_cant_access_attribute_not_shared() { let src = r#"permit(principal, action, resource == Group::"foo") when { (if 1 > 0 then User::"alice" else Photo::"vacation.jpg").name == "bob"};"#; let p = parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."); - assert_policy_typecheck_permissive_fails_simple_schema( - p, - [ValidationError::unsafe_attribute_access( + let errors = + assert_policy_typecheck_fails_for_mode(simple_schema_file(), p, ValidationMode::Permissive); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc( src, r#"(if 1 > 0 then User::"alice" else Photo::"vacation.jpg").name"#, @@ -623,7 +585,7 @@ fn entity_lub_cant_access_attribute_not_shared() { ), None, true, - )], + ) ); } @@ -631,17 +593,21 @@ fn entity_lub_cant_access_attribute_not_shared() { fn entity_attribute_recommendation() { let src = r#"permit(principal, action == Action::"view_photo", resource) when {resource.filetype like "*jpg" }; "#; let p = parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse"); - let expected = ValidationError::unsafe_attribute_access( - get_loc(src, "resource.filetype"), - PolicyID::from_string("0"), - AttributeAccess::EntityLUB( - EntityLUB::single_entity("Photo".parse().unwrap()), - Vec::from(["filetype".into()]), - ), - Some("file_type".into()), - false, + let errors = assert_policy_typecheck_fails(simple_schema_file(), p); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( + get_loc(src, "resource.filetype"), + PolicyID::from_string("0"), + AttributeAccess::EntityLUB( + EntityLUB::single_entity("Photo".parse().unwrap()), + Vec::from(["filetype".into()]), + ), + Some("file_type".into()), + false, + ) ); - assert_policy_typecheck_fails_simple_schema(p, [expected]); } #[test] @@ -659,27 +625,34 @@ fn entity_lub_cant_have_undeclared_attribute() { r#"permit(principal, action, resource) when { (if 1 > 0 then User::"alice" else Photo::"vacation.jpg") has foo};"#, ) .expect("Policy should parse."); - assert_policy_typecheck_permissive_warns_simple_schema( + let warnings = assert_policy_typecheck_warns_for_mode( + simple_schema_file(), p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("0"), - )], + ValidationMode::Permissive, + ); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("0"),) ); } #[test] fn is_typechecks_singleton() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy(None, r#"permit(principal is User, action, resource);"#).unwrap(), ); - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy(None, r#"permit(principal is Group, action, resource);"#).unwrap(), ); - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy(None, r#"permit(principal, action, resource is Photo);"#).unwrap(), ); - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy(None, r#"permit(principal, action, resource is Group);"#).unwrap(), ); } @@ -687,20 +660,18 @@ fn is_typechecks_singleton() { #[test] fn is_impossible() { let p = parse_policy(None, r#"permit(principal is Photo, action, resource);"#).unwrap(); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("policy0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("policy0"),) ); let p = parse_policy(None, r#"permit(principal, action, resource is User);"#).unwrap(); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("policy0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("policy0"),) ); } @@ -726,18 +697,22 @@ fn is_entity_lub() { "#, ) .unwrap(); - assert_policy_typecheck_permissive_warns_simple_schema( + let warnings = assert_policy_typecheck_warns_for_mode( + simple_schema_file(), p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("policy0"), - )], + ValidationMode::Permissive, + ); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("policy0"),) ); } #[test] fn is_action() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy( None, r#" @@ -753,21 +728,25 @@ fn is_action() { "#, ) .unwrap(); - assert_policy_typecheck_warns_simple_schema( - p.clone(), - [ValidationWarning::impossible_policy( - p.loc().cloned(), - PolicyID::from_string("policy0"), - )], + let warnings = assert_policy_typecheck_warns(simple_schema_file(), p.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(p.loc().cloned(), PolicyID::from_string("policy0"),) ); } #[test] fn entity_record_lub_is_none() { let src = r#"permit(principal, action, resource) when { (if 1 > 0 then User::"alice" else {name: "bob"}).name == "jane" };"#; - assert_policy_typecheck_fails_simple_schema( + let errors = assert_policy_typecheck_fails( + simple_schema_file(), parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."), - [ValidationError::incompatible_types( + ); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::incompatible_types( get_loc(src, r#"if 1 > 0 then User::"alice" else {name: "bob"}"#), PolicyID::from_string("0"), [ @@ -779,7 +758,7 @@ fn entity_record_lub_is_none() { ], LubHelp::EntityRecord, LubContext::Conditional, - )], + ) ); } @@ -819,17 +798,18 @@ fn optional_attr_fail() { let src = r#"permit(principal, action, resource) when { principal.name == "foo" };"#; let policy = parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."); - assert_policy_typecheck_fails( - schema, - policy, - [ValidationError::unsafe_optional_attribute_access( + let errors = assert_policy_typecheck_fails(schema, policy); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_optional_attribute_access( get_loc(src, "principal.name"), PolicyID::from_string("0"), AttributeAccess::EntityLUB( EntityLUB::single_entity("User".parse().unwrap()), vec!["name".into()], ), - )], + ) ); } @@ -857,16 +837,17 @@ fn type_error_is_not_reported_for_every_cross_product_element() { let src = r#"permit(principal, action, resource) when { 1 > true };"#; let policy = parse_policy(Some(PolicyID::from_string("0")), src).expect("Policy should parse."); - assert_policy_typecheck_fails( - schema, - policy, - [ValidationError::expected_type( + let errors = assert_policy_typecheck_fails(schema, policy); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::expected_type( get_loc(src, "true"), PolicyID::from_string("0"), Type::primitive_long(), Type::True, None, - )], + ) ); } @@ -914,13 +895,11 @@ fn action_groups() { r#"permit(principal, action, resource) when { action in Entity::"group" };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns( - schema.clone(), - policy.clone(), - [ValidationWarning::impossible_policy( - policy.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(schema.clone(), policy.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(policy.loc().cloned(), PolicyID::from_string("0"),) ); let policy = parse_policy( @@ -928,13 +907,11 @@ fn action_groups() { r#"permit(principal, action, resource) when { action in Entity::"act" };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns( - schema.clone(), - policy.clone(), - [ValidationWarning::impossible_policy( - policy.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(schema.clone(), policy.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(policy.loc().cloned(), PolicyID::from_string("0"),) ); let policy = parse_policy( @@ -942,13 +919,11 @@ fn action_groups() { r#"permit(principal, action, resource) when { Entity::"group" in action };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns( - schema.clone(), - policy.clone(), - [ValidationWarning::impossible_policy( - policy.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(schema.clone(), policy.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(policy.loc().cloned(), PolicyID::from_string("0"),) ); let policy = parse_policy( @@ -956,13 +931,11 @@ fn action_groups() { r#"permit(principal, action, resource) when { Entity::"act" in action };"#, ) .expect("Policy should parse."); - assert_policy_typecheck_warns( - schema, - policy.clone(), - [ValidationWarning::impossible_policy( - policy.loc().cloned(), - PolicyID::from_string("0"), - )], + let warnings = assert_policy_typecheck_warns(schema, policy.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy(policy.loc().cloned(), PolicyID::from_string("0"),) ); } @@ -1007,10 +980,11 @@ fn record_entity_lub_non_term() { let src = r#"permit(principal, action, resource) when {if principal.bar then principal.foo else U::"b"};"#; let policy = parse_policy(None, src).expect("Policy should parse."); - assert_policy_typecheck_fails( - schema, - policy, - [ValidationError::incompatible_types( + let errors = assert_policy_typecheck_fails(schema, policy); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::incompatible_types( get_loc(src, r#"if principal.bar then principal.foo else U::"b""#), PolicyID::from_string("policy0"), [ @@ -1022,7 +996,7 @@ fn record_entity_lub_non_term() { ], LubHelp::EntityRecord, LubContext::Conditional, - )], + ) ); } @@ -1072,7 +1046,8 @@ mod templates { #[test] fn principal_eq_slot() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy_or_template( None, r#"permit(principal == ?principal, action, resource);"#, @@ -1083,7 +1058,8 @@ mod templates { #[test] fn resource_eq_slot() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy_or_template(None, r#"permit(principal, action, resource == ?resource);"#) .unwrap(), ); @@ -1091,7 +1067,8 @@ mod templates { #[test] fn principal_resource_eq_slot() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy_or_template( None, r#"permit(principal == ?principal, action, resource == ?resource);"#, @@ -1102,7 +1079,8 @@ mod templates { #[test] fn principal_in_slot() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy_or_template( None, r#"permit(principal in ?principal, action, resource);"#, @@ -1113,7 +1091,8 @@ mod templates { #[test] fn resource_in_slot() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy_or_template(None, r#"permit(principal, action, resource in ?resource);"#) .unwrap(), ); @@ -1121,7 +1100,8 @@ mod templates { #[test] fn principal_resource_in_slot() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy_or_template( None, r#"permit(principal in ?principal, action, resource in ?resource);"#, @@ -1132,7 +1112,8 @@ mod templates { #[test] fn resource_slot_safe_body() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy_or_template( None, r#"permit(principal, action, resource in ?resource) when { resource in Group::"Friends" && resource.name like "*" };"#, @@ -1144,9 +1125,14 @@ mod templates { #[test] fn resource_slot_error_body() { let src = r#"permit(principal, action, resource in ?resource) when { resource in Group::"Friends" && resource.bogus };"#; - assert_policy_typecheck_fails_simple_schema( + let errors = assert_policy_typecheck_fails( + simple_schema_file(), parse_policy_or_template(None, src).unwrap(), - [ValidationError::unsafe_attribute_access( + ); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc(src, "resource.bogus"), PolicyID::from_string("policy0"), AttributeAccess::EntityLUB( @@ -1155,13 +1141,14 @@ mod templates { ), Some("name".to_string()), false, - )], + ) ); } #[test] fn principal_slot_safe_body() { - assert_policy_typechecks_simple_schema( + assert_policy_typechecks( + simple_schema_file(), parse_policy_or_template( None, r#"permit(principal == ?principal, action, resource in ?resource) when { principal has age && principal.age > 0};"#, @@ -1173,9 +1160,14 @@ mod templates { #[test] fn principal_slot_error_body() { let src = r#"permit(principal == ?principal, action, resource) when { principal has age && principal.bogus > 0 };"#; - assert_policy_typecheck_fails_simple_schema( + let errors = assert_policy_typecheck_fails( + simple_schema_file(), parse_policy_or_template(None, src).unwrap(), - [ValidationError::unsafe_attribute_access( + ); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::unsafe_attribute_access( get_loc(src, "principal.bogus"), PolicyID::from_string("policy0"), AttributeAccess::EntityLUB( @@ -1184,7 +1176,7 @@ mod templates { ), Some("age".to_string()), false, - )], + ) ); } @@ -1195,12 +1187,14 @@ mod templates { r#"permit(principal == ?principal, action, resource) when { false };"#, ) .unwrap(); - assert_policy_typecheck_warns_simple_schema( - template.clone(), - [ValidationWarning::impossible_policy( + let warnings = assert_policy_typecheck_warns(simple_schema_file(), template.clone()); + let warning = assert_exactly_one_diagnostic(warnings); + assert_eq!( + warning, + ValidationWarning::impossible_policy( template.loc().cloned(), PolicyID::from_string("policy0"), - )], + ) ); } } diff --git a/cedar-policy-validator/src/typecheck/test/strict.rs b/cedar-policy-validator/src/typecheck/test/strict.rs index fac7cd615a..98234012d4 100644 --- a/cedar-policy-validator/src/typecheck/test/strict.rs +++ b/cedar-policy-validator/src/typecheck/test/strict.rs @@ -37,7 +37,9 @@ use crate::{ RawName, ValidationError, ValidationMode, }; -use super::test_utils::{assert_policy_typecheck_fails, expr_id_placeholder, get_loc}; +use super::test_utils::{ + assert_exactly_one_diagnostic, assert_policy_typecheck_fails, expr_id_placeholder, get_loc, +}; #[track_caller] // report the caller's location as the location of the panic, not the location in this function fn assert_typechecks_strict( @@ -719,10 +721,11 @@ fn qualified_record_attr() { let src = "permit(principal, action, resource) when { context == {num_of_things: 1}};"; let p = parse_policy_or_template(None, src).unwrap(); - assert_policy_typecheck_fails( - schema, - p.clone(), - [ValidationError::incompatible_types( + let errors = assert_policy_typecheck_fails(schema, p.clone()); + let error = assert_exactly_one_diagnostic(errors); + assert_eq!( + error, + ValidationError::incompatible_types( get_loc(src, "context == {num_of_things: 1}"), PolicyID::from_string("policy0"), [ @@ -743,6 +746,6 @@ fn qualified_record_attr() { ], LubHelp::AttributeQualifier, LubContext::Equality, - )], + ) ); } diff --git a/cedar-policy-validator/src/typecheck/test/tags.rs b/cedar-policy-validator/src/typecheck/test/tags.rs new file mode 100644 index 0000000000..80589872a4 --- /dev/null +++ b/cedar-policy-validator/src/typecheck/test/tags.rs @@ -0,0 +1,425 @@ +/* + * Copyright Cedar Contributors + * + * 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. + */ + +//! Contains tests for defining entity tags and typechecking their +//! access using the ability added by capabilities. +// GRCOV_STOP_COVERAGE + +#![cfg(feature = "entity-tags")] + +use super::test_utils::{ + assert_exactly_one_diagnostic, assert_policy_typecheck_fails, + assert_policy_typecheck_fails_for_mode, assert_policy_typecheck_warns, + assert_policy_typechecks, assert_policy_typechecks_for_mode, +}; +use crate::ValidationMode; +use cedar_policy_core::{ + ast::PolicyID, + parser::parse_policy, + test_utils::{expect_err, ExpectedErrorMessageBuilder}, +}; +use cool_asserts::assert_matches; +use itertools::Itertools; + +fn schema_with_tags() -> &'static str { + r#" + entity E tags String; + entity F { foo: String, opt?: String } tags Set; + entity Blank; + action A1 appliesTo { + principal: [E], + resource: [F], + context: { bool: Bool }, + }; + action A2 appliesTo { + principal: [F], + resource: [E], + context: { bool: Bool }, + }; + action A3 appliesTo { + principal: [E, F], + resource: [E, F], + context: { bool: Bool }, + }; + action A4 appliesTo { + principal: [E], + resource: [Blank], + context: { bool: Bool }, + }; + "# +} + +#[test] +fn tag_access_success() { + // constant-keys case + let policy = parse_policy( + Some(PolicyID::from_string("0")), + r#" + permit(principal, action == Action::"A1", resource) when { + principal.hasTag("foo") && principal.getTag("foo") == "foo" + }; + "#, + ) + .unwrap(); + assert_policy_typechecks(schema_with_tags(), policy); + + // computed-keys case + let policy = parse_policy( + Some(PolicyID::from_string("0")), + r#" + permit(principal, action == Action::"A1", resource) when { + principal.hasTag(resource.foo) && principal.getTag(resource.foo) == "foo" + }; + "#, + ) + .unwrap(); + assert_policy_typechecks(schema_with_tags(), policy); +} + +#[test] +fn tag_access_missing_has_check() { + // constant-keys case + let src = r#" + permit(principal, action == Action::"A1", resource) when { + principal.getTag("foo") == "foo" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error(r#"for policy `0`, unable to guarantee safety of access to tag `"foo"` on entity type `E`"#) + .help(r#"try testing for the tag's presence with `.hasTag("foo") && ..`"#) + .exactly_one_underline(r#"principal.getTag("foo")"#) + .build(), + ); + + // computed-keys case + let src = r#" + permit(principal, action == Action::"A1", resource) when { + principal.getTag(resource.foo) == "foo" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error(r#"for policy `0`, unable to guarantee safety of access to tag `resource["foo"]` on entity type `E`"#) + .help(r#"try testing for the tag's presence with `.hasTag(resource["foo"]) && ..`"#) + .exactly_one_underline(r#"principal.getTag(resource.foo)"#) + .build(), + ); +} + +#[test] +fn tag_access_type_error() { + // constant-keys case + let src = r#" + permit(principal, action == Action::"A1", resource) when { + principal.hasTag("foo") && principal.getTag("foo").contains("bar") + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error(r#"for policy `0`, unexpected type: expected Set<__cedar::internal::Any> but saw String"#) + .help("try using `like` to examine the contents of a string") + .exactly_one_underline(r#"principal.getTag("foo").contains("bar")"#) + .build(), + ); + + // computed-keys case + let src = r#" + permit(principal, action == Action::"A1", resource) when { + principal.hasTag(resource.foo) && principal.getTag(resource.foo).contains("bar") + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error(r#"for policy `0`, unexpected type: expected Set<__cedar::internal::Any> but saw String"#) + .help("try using `like` to examine the contents of a string") + .exactly_one_underline(r#"principal.getTag(resource.foo).contains("bar")"#) + .build(), + ); + + // works for one principal type this action applies to, but not for all + let src = r#" + permit(principal, action == Action::"A3", resource) when { + principal.hasTag("foo") && principal.getTag("foo") == "bar" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error(r#"the types String and Set are not compatible"#) + .help("for policy `0`, both operands to a `==` expression must have compatible types. Types must be exactly equal to be compatible") + .exactly_one_underline(r#"principal.getTag("foo") == "bar""#) + .build(), + ); + + // works for one action this policy applies to, but not for all + let src = r#" + permit(principal, action in [Action::"A1", Action::"A2"], resource) when { + principal.hasTag("foo") && principal.getTag("foo") == "bar" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error(r#"the types String and Set are not compatible"#) + .help("for policy `0`, both operands to a `==` expression must have compatible types. Types must be exactly equal to be compatible") + .exactly_one_underline(r#"principal.getTag("foo") == "bar""#) + .build(), + ); +} + +#[test] +fn no_tags_allowed() { + // .hasTag() on an entity with no tags is allowed + let src = r#" + permit(principal, action == Action::"A4", resource) when { + resource.hasTag("foo") + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + assert_policy_typechecks(schema_with_tags(), policy); + + // .getTag() on an entity with no tags is not allowed + let src = r#" + permit(principal, action == Action::"A4", resource) when { + resource.getTag("foo") == "bar" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error(r#"for policy `0`, unable to guarantee safety of access to tag `"foo"` on entity type `Blank`"#) + .help(r#"try testing for the tag's presence with `.hasTag("foo") && ..`"#) + .exactly_one_underline(r#"resource.getTag("foo")"#) + .build(), + ); + + // .getTag() on an entity with no tags _is_ allowed if guarded by an + // appropriate `.hasTag()` check, because of short-circuiting + let src = r#" + permit(principal, action == Action::"A4", resource) when { + resource.hasTag("foo") && resource.getTag("foo") == "bar" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + assert_policy_typechecks(schema_with_tags(), policy); +} + +/// having a capability for attribute "foo" doesn't let you access tag "foo", and vice versa +#[test] +fn mixed_tags_and_attrs() { + // have attr capability, try to access tag + let src = r#" + permit(principal, action == Action::"A1", resource) when { + resource has opt && resource.getTag("opt").contains("foo") + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error(r#"for policy `0`, unable to guarantee safety of access to tag `"opt"` on entity type `F`"#) + .help(r#"try testing for the tag's presence with `.hasTag("opt") && ..`"#) + .exactly_one_underline(r#"resource.getTag("opt").contains("foo")"#) // why does the `.contains("foo")` part get underlined? + .build(), + ); + + // have tag capability, try to access attr + let src = r#" + permit(principal, action == Action::"A1", resource) when { + resource.hasTag("opt") && resource.opt == "foo" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error(r#"for policy `0`, unable to guarantee safety of access to optional attribute `opt` on entity type `F`"#) + .help(r#"try testing for the attribute's presence with `e has opt && ..`"#) + .exactly_one_underline("resource.opt") + .build(), + ); + + // gaining a capability for an attr doesn't wipe out your capability for the tag + let src = r#" + permit(principal, action == Action::"A1", resource) when { + resource.hasTag("opt") && resource has opt && resource.getTag("opt").contains("bar") + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + assert_policy_typechecks(schema_with_tags(), policy); + + // gaining a capability for a tag doesn't wipe out your capability for the attr + let src = r#" + permit(principal, action == Action::"A1", resource) when { + resource has opt && resource.hasTag("opt") && resource.opt == "bar" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + assert_policy_typechecks(schema_with_tags(), policy); +} + +#[test] +fn tags_on_actions() { + // hasTag on an action. This succeeds, although warns that it's always false + let src = r#" + permit(principal, action == Action::"A1", resource) when { + action.hasTag("foo") + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let warnings = assert_policy_typecheck_warns(schema_with_tags(), policy); + let warning = assert_exactly_one_diagnostic(warnings); + expect_err( + src, + &miette::Report::new(warning), + &ExpectedErrorMessageBuilder::error("for policy `0`, policy is impossible: the policy expression evaluates to false for all valid requests") + .exactly_one_underline(r#"permit(principal, action == Action::"A1", resource) when { + action.hasTag("foo") + };"#) + .build(), + ); + + // getTag on an action. This fails + let src = r#" + permit(principal, action == Action::"A4", resource) when { + action.getTag("foo") == "bar" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails(schema_with_tags(), policy); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error( + r#"for policy `0`, unable to guarantee safety of access to tag `"foo"`"#, + ) + .help(r#"try testing for the tag's presence with `.hasTag("foo") && ..`"#) + .exactly_one_underline(r#"action.getTag("foo")"#) + .build(), + ); +} + +#[test] +fn permissive_tags() { + // LUB with only one valid tag type, this is fine in permissive mode + let src = r#" + permit(principal, action == Action::"A1", resource) when { + (if context.bool then principal else Blank::"").hasTag("foo") && + (if context.bool then principal else Blank::"").getTag("foo") == "bar" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + assert_policy_typechecks_for_mode(schema_with_tags(), policy, ValidationMode::Permissive); + + // (that policy is an error in strict mode though) + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = + assert_policy_typecheck_fails_for_mode(schema_with_tags(), policy, ValidationMode::Strict); + // two errors, one for each if-then-else + assert_eq!( + errors.len(), + 2, + "actual errors were:\n\n{}", + errors + .into_iter() + .map(|e| format!("{:?}", miette::Report::new(e))) + .join("\n\n") + ); + // we only check one error, because they're identical other than which if-then-else is underlined + expect_err( + src, + &miette::Report::new(errors.into_iter().next().expect("already checked that len is 2")), + &ExpectedErrorMessageBuilder::error("the types Blank and E are not compatible") + .help("for policy `0`, both branches of a conditional must have compatible types. Different entity types are never compatible even when their attributes would be compatible") + .exactly_one_underline(r#"if context.bool then principal else Blank::"""#) + .build(), + ); + + // LUB with multiple valid tag types, not fine even in permissive mode + let src = r#" + permit(principal, action == Action::"A1", resource) when { + (if context.bool then principal else resource).hasTag("foo") && + (if context.bool then principal else resource).getTag("foo") == "bar" + }; + "#; + let policy = parse_policy(Some(PolicyID::from_string("0")), src).unwrap(); + let errors = assert_policy_typecheck_fails_for_mode( + schema_with_tags(), + policy, + ValidationMode::Permissive, + ); + let error = assert_exactly_one_diagnostic(errors); + expect_err( + src, + &miette::Report::new(error), + &ExpectedErrorMessageBuilder::error("the types String and Set are not compatible") + .help("for policy `0`, tag types for a `.getTag()` operation must have compatible types. Types must be exactly equal to be compatible") + .exactly_one_underline(r#"(if context.bool then principal else resource).getTag("foo")"#) + .build(), + ); +} + +/// Not a test of tag functionality itself, but just double-checking that +/// although tags support computed keys (as evidenced by above tests), +/// attributes do not +#[test] +fn computed_attribute_fails() { + let src = r#" + permit(principal, action == Action::"A1", resource) when { + principal.hasTag("foo") && resource[principal.getTag("foo")] == "bar" + }; + "#; + assert_matches!(parse_policy(Some(PolicyID::from_string("0")), src), Err(e) => { + expect_err( + src, + &miette::Report::new(e), + &ExpectedErrorMessageBuilder::error(r#"invalid string literal: principal.getTag("foo")"#) + .exactly_one_underline(r#"principal.getTag("foo")"#) + .build() + ) + }); +} diff --git a/cedar-policy-validator/src/typecheck/test/test_utils.rs b/cedar-policy-validator/src/typecheck/test/test_utils.rs index ceccb7239e..a2304f7b29 100644 --- a/cedar-policy-validator/src/typecheck/test/test_utils.rs +++ b/cedar-policy-validator/src/typecheck/test/test_utils.rs @@ -19,7 +19,8 @@ // GRCOV_STOP_COVERAGE use cool_asserts::assert_matches; -use std::{collections::HashSet, sync::Arc}; +use itertools::Itertools; +use std::{collections::HashSet, hash::Hash, sync::Arc}; use cedar_policy_core::ast::{EntityUID, Expr, PolicyID, Template, ACTION_ENTITY_TYPE}; use cedar_policy_core::extensions::Extensions; @@ -121,26 +122,16 @@ pub(crate) fn assert_types_eq(schema: &ValidatorSchema, expected: &Type, actual: "Type equality assertion failed: the actual type is not a subtype of the expected type.\nexpected: {:#?}\nactual: {:#?}", expected, actual); } -/// Assert that every [`ValidationError`] in the expected list of type errors appears -/// in the expected list of type errors, and that the expected number of -/// type errors were generated. -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -pub(crate) fn assert_expected_type_errors( - expected: impl IntoIterator, - actual: &HashSet, -) { - assert_eq!(&expected.into_iter().collect::>(), actual) -} - -/// Assert that every `ValidationWarning` in the expected list of warnings -/// appears in the expected list of warnings, and that the expected number of -/// warnings were generated. -#[track_caller] // report the caller's location as the location of the panic, not the location in this function -pub(crate) fn assert_expected_warnings( - expected: impl IntoIterator, - actual: &HashSet, +/// Assert that every `T` in `actual` appears in `expected`, and vice versa. +#[track_caller] +pub(crate) fn assert_sets_equal( + expected: impl IntoIterator, + actual: impl IntoIterator, ) { - assert_eq!(&expected.into_iter().collect::>(), actual,) + assert_eq!( + expected.into_iter().collect::>(), + actual.into_iter().collect::>(), + ); } /// Unifies a bunch of different ways we specify schemas in tests @@ -204,88 +195,103 @@ pub(crate) fn assert_policy_typechecks_for_mode( let mut type_errors: HashSet = HashSet::new(); let mut warnings: HashSet = HashSet::new(); let typechecked = typechecker.typecheck_policy(&policy, &mut type_errors, &mut warnings); - assert_eq!(type_errors, HashSet::new(), "Did not expect any errors."); - assert!(typechecked, "Expected that policy would typecheck."); + if !type_errors.is_empty() { + let mut pretty_type_errors = type_errors + .into_iter() + .map(|e| format!("{:?}", miette::Report::new(e))); + panic!( + "typechecking failed with mode {:?}:\n\n{}", + typechecker.mode, + pretty_type_errors.join("\n\n") + ); + } + assert!( + typechecked, + "Unexpected failure with mode {:?}: no errors, but typechecker reported failure", + typechecker.mode + ); // Ensure that partial schema validation doesn't cause any policy that // should validate with a complete schema to no longer validate with the // same complete schema. typechecker.mode = ValidationMode::Permissive; let typechecked = typechecker.typecheck_policy(&policy, &mut type_errors, &mut warnings); - assert_eq!( - type_errors, - HashSet::new(), - "Did not expect any errors under partial schema validation." - ); + if !type_errors.is_empty() { + let mut pretty_type_errors = type_errors + .into_iter() + .map(|e| format!("{:?}", miette::Report::new(e))); + panic!( + "typechecking failed with mode {:?}:\n\n{}", + typechecker.mode, + pretty_type_errors.join("\n\n") + ); + } assert!( typechecked, - "Expected that policy would typecheck under partial schema validation." + "Unexpected failure with mode {:?}: no errors, but typechecker reported failure", + typechecker.mode ); } +/// Assert that the policy fails to typecheck, and return a `HashSet` of the validation errors encountered #[track_caller] // report the caller's location as the location of the panic, not the location in this function pub(crate) fn assert_policy_typecheck_fails( schema: impl SchemaProvider, policy: impl Into>, - expected_type_errors: impl IntoIterator, -) { - assert_policy_typecheck_fails_for_mode( - schema, - policy, - expected_type_errors, - ValidationMode::Strict, - ) +) -> HashSet { + assert_policy_typecheck_fails_for_mode(schema, policy, ValidationMode::Strict) } +/// Assert that the policy typechecks successfully, but returns warnings. +/// Returns a `HashSet` of the validation warnings encountered (which will not be empty) #[track_caller] // report the caller's location as the location of the panic, not the location in this function pub(crate) fn assert_policy_typecheck_warns( schema: impl SchemaProvider, policy: impl Into>, - expected_warnings: impl IntoIterator, -) { - assert_policy_typecheck_warns_for_mode( - schema, - policy, - expected_warnings, - ValidationMode::Strict, - ) +) -> HashSet { + assert_policy_typecheck_warns_for_mode(schema, policy, ValidationMode::Strict) } +/// Assert that the policy fails to typecheck, and return a `HashSet` of the validation errors encountered #[track_caller] // report the caller's location as the location of the panic, not the location in this function pub(crate) fn assert_policy_typecheck_fails_for_mode( schema: impl SchemaProvider, policy: impl Into>, - expected_type_errors: impl IntoIterator, mode: ValidationMode, -) { +) -> HashSet { let policy = policy.into(); let schema = schema.schema(); let typechecker = Typechecker::new(&schema, mode, policy.id().clone()); let mut type_errors: HashSet = HashSet::new(); let mut warnings: HashSet = HashSet::new(); let typechecked = typechecker.typecheck_policy(&policy, &mut type_errors, &mut warnings); - assert_expected_type_errors(expected_type_errors, &type_errors); assert!(!typechecked, "Expected that policy would not typecheck."); + type_errors } +/// Assert that the policy typechecks successfully, but returns warnings. +/// Returns a `HashSet` of the validation warnings encountered (which will not be empty) #[track_caller] // report the caller's location as the location of the panic, not the location in this function pub(crate) fn assert_policy_typecheck_warns_for_mode( schema: impl SchemaProvider, policy: impl Into>, - expected_warnings: impl IntoIterator, mode: ValidationMode, -) { +) -> HashSet { let policy = policy.into(); let schema = schema.schema(); let typechecker = Typechecker::new(&schema, mode, policy.id().clone()); let mut type_errors: HashSet = HashSet::new(); let mut warnings: HashSet = HashSet::new(); let typechecked = typechecker.typecheck_policy(&policy, &mut type_errors, &mut warnings); - assert_expected_warnings(expected_warnings, &warnings); assert!( typechecked, "Expected that policy would typecheck (with warnings)." ); + assert!( + !warnings.is_empty(), + "Expected that policy would produce a warning, but found none" + ); + warnings } /// Assert that expr type checks successfully with a particular type, and @@ -317,34 +323,34 @@ pub(crate) fn assert_typechecks_for_mode( ); } -/// Assert that typechecking fails, generating some `ValidationErrors` for the -/// expressions. Failed type checking will still return a type that is used -/// to continue typechecking, so the `expected` type must match the returned -/// type for this to pass. +/// Assert that typechecking fails for the given `Expr`, and return a `HashSet` +/// of the `ValidationErrors` encountered. +/// +/// Failed typechecking still returns a type that is used to continue +/// typechecking; this method also checks that this returned type matches +/// `expected_ty`. #[track_caller] // report the caller's location as the location of the panic, not the location in this function pub(crate) fn assert_typecheck_fails( schema: impl SchemaProvider, expr: Expr, expected_ty: Option, - expected_type_errors: impl IntoIterator, -) { - assert_typecheck_fails_for_mode( - schema, - expr, - expected_ty, - expected_type_errors, - ValidationMode::Strict, - ) +) -> HashSet { + assert_typecheck_fails_for_mode(schema, expr, expected_ty, ValidationMode::Strict) } +/// Assert that typechecking fails for the given `Expr`, and return a `HashSet` +/// of the `ValidationErrors` encountered. +/// +/// Failed typechecking still returns a type that is used to continue +/// typechecking; this method also checks that this returned type matches +/// `expected_ty`. #[track_caller] // report the caller's location as the location of the panic, not the location in this function pub(crate) fn assert_typecheck_fails_for_mode( schema: impl SchemaProvider, expr: Expr, expected_ty: Option, - expected_type_errors: impl IntoIterator, mode: ValidationMode, -) { +) -> HashSet { let schema = schema.schema(); let typechecker = Typechecker::new(&schema, mode, expr_id_placeholder()); let mut type_errors = HashSet::new(); @@ -357,8 +363,8 @@ pub(crate) fn assert_typecheck_fails_for_mode( } _ => panic!("Expected that actual type would be defined iff expected type is defined."), } - assert_expected_type_errors(expected_type_errors, &type_errors); }); + type_errors } pub(crate) fn empty_schema_file() -> json_schema::NamespaceDefinition { @@ -384,15 +390,32 @@ pub(crate) fn assert_typechecks_empty_schema_permissive(expr: Expr, expected: Ty pub(crate) fn assert_typecheck_fails_empty_schema( expr: Expr, expected: Type, - type_errors: impl IntoIterator, -) { - assert_typecheck_fails(empty_schema_file(), expr, Some(expected), type_errors); +) -> HashSet { + assert_typecheck_fails(empty_schema_file(), expr, Some(expected)) } #[track_caller] // report the caller's location as the location of the panic, not the location in this function pub(crate) fn assert_typecheck_fails_empty_schema_without_type( expr: Expr, - type_errors: impl IntoIterator, -) { - assert_typecheck_fails(empty_schema_file(), expr, None, type_errors); +) -> HashSet { + assert_typecheck_fails(empty_schema_file(), expr, None) +} + +/// Assert that the given `HashSet` has exactly one `Diagnostic`. Return it. +/// If there are more than one, panic and display all the `Diagnostic`s in pretty format. +#[track_caller] +pub(crate) fn assert_exactly_one_diagnostic( + set: HashSet, +) -> T { + match set.len() { + 0 => panic!("expected exactly one error, but got no errors"), + 1 => set.into_iter().next().unwrap(), + 2.. => panic!( + "expected exactly one error, but got {}:\n\n{}", + set.len(), + set.into_iter() + .map(|e| format!("{:?}", &miette::Report::new(e))) + .join("\n\n") + ), + } } diff --git a/cedar-policy-validator/src/types/capability.rs b/cedar-policy-validator/src/types/capability.rs index eaf6f0b0c1..304ba73a55 100644 --- a/cedar-policy-validator/src/types/capability.rs +++ b/cedar-policy-validator/src/types/capability.rs @@ -14,6 +14,7 @@ * limitations under the License. */ +use smol_str::SmolStr; use std::collections::HashSet; use cedar_policy_core::ast::{Expr, ExprShapeOnly}; @@ -57,18 +58,52 @@ impl<'a> CapabilitySet<'a> { #[derive(Hash, Eq, PartialEq, Debug, Clone)] pub struct Capability<'a> { /// For this expression - on_expr: ExprShapeOnly<'a>, - /// This attribute is known to exist on that expression - attribute: &'a str, + on_expr: ExprShapeOnly<'a, ()>, + /// This attribute or tag is known to exist on that expression + /// + /// This expression represents the attribute or tag name. It should have type string. + /// Often this is a string constant, but in the case of tags it can be an expression. + attribute_or_tag: ExprShapeOnly<'a, ()>, + /// Is `attribute_or_tag` an attribute name or a tag name + kind: CapabilityKind, +} + +#[derive(Hash, Eq, PartialEq, Debug, Clone, Copy)] +enum CapabilityKind { + /// This capability is for accessing attributes + Attribute, + /// This capability is for accessing tags + Tag, } impl<'a> Capability<'a> { /// Construct a new [`Capability`] stating that the attribute `attribute` is /// known to exist for the expression `on_expr` - pub fn new(on_expr: &'a Expr, attribute: &'a str) -> Self { + pub fn new_attribute(on_expr: &'a Expr<()>, attribute: SmolStr) -> Self { + Self { + on_expr: ExprShapeOnly::new_from_borrowed(on_expr), + attribute_or_tag: ExprShapeOnly::new_from_owned(Expr::val(attribute)), + kind: CapabilityKind::Attribute, + } + } + + /// Construct a new [`Capability`] stating that the tag `tag` is + /// known to exist for the expression `on_expr` + pub fn new_borrowed_tag(on_expr: &'a Expr<()>, tag: &'a Expr<()>) -> Self { + Self { + on_expr: ExprShapeOnly::new_from_borrowed(on_expr), + attribute_or_tag: ExprShapeOnly::new_from_borrowed(tag), + kind: CapabilityKind::Tag, + } + } + + /// Construct a new [`Capability`] stating that the tag `tag` is + /// known to exist for the expression `on_expr` + pub fn new_owned_tag(on_expr: &'a Expr<()>, tag: Expr<()>) -> Self { Self { - on_expr: ExprShapeOnly::new(on_expr), - attribute, + on_expr: ExprShapeOnly::new_from_borrowed(on_expr), + attribute_or_tag: ExprShapeOnly::new_from_owned(tag), + kind: CapabilityKind::Tag, } } } diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md index 92a8071a3d..ebfa9971f2 100644 --- a/cedar-policy/CHANGELOG.md +++ b/cedar-policy/CHANGELOG.md @@ -1,5 +1,739 @@ # Changelog +<<<<<<< HEAD The changelog for all the release branches of `cedar-policy` is maintained on the `main` branch. You can view the most up-to-date changelog [here](https://github.com/cedar-policy/cedar/blob/main/cedar-policy/CHANGELOG.md). +======= +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +The "Cedar Language Version" refers to the language version as documented in the [Cedar Policy Language Guide](https://docs.cedarpolicy.com/other/doc-history.html). The language version may differ from the Rust crate version because a breaking change for the Cedar Rust API may or may not be a breaking change for the Cedar language. + +Starting with version 3.2.4, changes marked with a star (*) are _language breaking changes_, meaning that they have the potential to affect users of Cedar, beyond users of the `cedar-policy` Rust crate. Changes marked with a star change the behavior of a Cedar parser, the authorization engine, or policy validator. + +## [Unreleased] +Cedar Language Version: TBD + +### Added + +- Added `get_entity_literals` API (#1149). +- Implemented [RFC 82](https://github.com/cedar-policy/rfcs/pull/82), adding + entity tags to the Cedar language under experimental flag `entity-tags` (#1204, #1207, #1213, #1218) +- Implemented [RFC 74](https://github.com/cedar-policy/rfcs/pull/74): A new experimental API (`compute_entity_manifest`) + that provides the Entity Manifest: a data + structure that describes what data is required to satisfy a + Cedar request. To use this API you must enable the `entity-manifest` feature flag. +- Added public APIs to get language and SDK version numbers (#1219). + +### Fixed + +- The formatter will now consistently add a trailing newline. (resolving #1217) + +## [4.0.0] - 2024-09-16 +Cedar Language Version: 4.0 + +### Added + +- Additional functionality to the JSON FFI including parsing utilities (#1079) + and conversion between the Cedar and JSON formats (#1087) +- (*) Schema JSON syntax now accepts a type `EntityOrCommon` representing a + typename that can resolve to either an entity or common type, matching the + behavior of typenames written in the human-readable (Cedar) syntax. (#1060, as + part of resolving #579) + +### Changed + +- (*) Implemented [RFC 70](https://github.com/cedar-policy/rfcs/blob/main/text/0070-disallow-empty-namespace-shadowing.md). + In both the Cedar and JSON schema syntaxes, it is now illegal to define the + same entity name, common type name, or action name in both the empty namespace + and a nonempty namespace. +- (*) Implemented [RFC 52](https://github.com/cedar-policy/rfcs/blob/main/text/0052-reserved-namespaces.md). + Names containing `__cedar` (e.g., `__cedar`, `A::__cedar`, `__cedar::A`, and + `A::__cedar::B`) are now invalid. (#969) +- The API around `Request::new` has changed to remove the `Option`s + around the entity type arguments. See [RFC 55](https://github.com/cedar-policy/rfcs/blob/main/text/0055-remove-unspecified.md). +- Replaced uses of "natural", "human", "human-readable", and "custom" with "Cedar" (#1114). + APIs with these names are changed accordingly. E.g., `Schema::from_str_natural` to `Schema::from_cedarschema_str`. + Moreover, the `FromStr` implementations of `Schema` and `SchemaFragment` + now parse strings in the Cedar schema format. Use `Schema::from_json_str` and `SchemaFragment::from_json_str` + to parse strings in the JSON schema format. +- Significantly reworked all public-facing error types to address some issues + and improve consistency. See issue #745. +- Finalized the `ffi` module and `cedar-wasm` crate which were preview-released + in 3.2.0. This involved API breaking changes in both. See #757 and #854. +- (*) Changed policy validation to reject comparisons and conditionals between + record types that differ in whether an attribute is required or optional. (#769) +- (*) Improved consistency between JSON and Cedar schema formats. Both now + forbid using `Bool`, `Boolean`, `Entity`, `Extension`, `Long`, `Record`, `Set`, + and `String` as common type names. (#1150, resolving #1139) +- Changed the FFI to error on typos or unexpected fields in the input JSON (#1041) +- Changed `Policy::parse` and `Template::parse` to accept an `Option` + instead of `Option` to set the policy id (#1055, resolving #1049) +- `PolicySet::template_annotation` now returns `Option<&str>` as opposed to + `Option` in the previous version (#1131, resolving #1116) +- Moved `::Err` to `Infallible` (#588, resolving #551) +- Removed unnecessary lifetimes from some validation related structs (#715) +- Marked errors/warnings related to parsing and validation as `non_exhaustive`, + allowing future variants to be added without a breaking change. (#1137, #1169) + +### Removed + +- (*) Removed unspecified entity type. See [RFC 55](https://github.com/cedar-policy/rfcs/blob/main/text/0055-remove-unspecified.md). +- Removed integration testing harness from the `cedar-policy` crate. It is now + in an internal crate, allowing us to make semver incompatible changes. (#857) +- Removed the (deprecated) `frontend` module in favor of the new `ffi` module + introduced in 3.2.0. See #757. +- Removed `ParseErrors::errors_as_strings`. Callers should consider examining + the rich data provided by `miette::Diagnostic`, for instance `.help()` and + `labels()`. Callers can continue using the same behavior by calling + `.iter().map(ToString::to_string)`. (#882, resolving #543) +- Removed `ParseError::primary_source_span`. Callers should use the location + information provided by `miette::Diagnostic` via `.labels()` and + `.source_code()` instead. (#908) +- Removed `Display` impl for `EntityId` in favor of explicit `.escaped()` and + `.as_ref()` for escaped and unescaped representations (respectively) of the + `EntityId`; see note there (#921, resolving #884) + +### Fixed + +- (*) JSON format Cedar schemas will now fail to parse if they reference an unknown + extension type. This was already an error for human-readable schema syntax. (#890, resolving #875) +- (*) Schemas can now reference entity and common types defined in the empty namespace, + even in contexts occurring in a non-empty namespace. (#1060, resolving #579) + +## [3.4.0] - 2024-09-16 +Cedar Language Version: 3.4 + +### Added + +- Convenience methods `num_of_policies()` and `num_of_templates()` to see how + many policies and templates a policy set has (#1180) +- `Entity` is now `Hash`. The hash implementation compares the hash of + the entity UID (#1186) + +### Fixed + +- (*) `Entities::from_entities()` will now correctly reject record + attributes with superfluous attributes. (#1177, resolving #1176) + +## [3.3.0] - 2024-08-19 +Cedar Language Version: 3.4 + +### Added + +- JSON representation for Policy Sets, along with methods like + `::from_json_value/file/str` and `::to_json` for `PolicySet`. (#783, + resolving #549) +- Methods for reading and writing individual `Entity`s as JSON (#924, + resolving #807) +- `Context::into_iter` to get the contents of a `Context` and `Context::merge` + to combine `Context`s, returning an error on duplicate keys (#1027, + resolving #1013) +- Several new APIs for schemas to allow accessing principal and resource + types, action entity uids, etc. (#1141, resolving #1134) + +### Changed + +- Added deprecation warnings to APIs that will be removed in the upcoming 4.0 + release, as well as wrapper methods with the new names, where appropriate. + See the notes under that release for more details. (#1128) +- Reduced precision of partial evaluation for `||`, `&&`, and conditional + expressions. `if { foo : }.foo then 1 + "hi" else false` now + evaluates to `if then 1 + "hi" else false`. (#874) +- Removed the `error` extension function, which was previously used during + partial evaluation. (#874) + +### Fixed + +- (*) JSON format Cedar policies will now fail to parse if the action scope + constraint contains a non-action entity type, matching the behavior for + human-readable Cedar policies. (#943, resolving #925) +- `Template` parsing functions (e.g., `Template::parse()`) will now fail when + passed a static policy as input. Use the `Policy` parsing functions instead. + (#1108, resolving #1095) + +## [3.2.4] - 2024-08-07 +Cedar Language Version: 3.3 + +_Note:_ 3.2.2 and 3.2.3 skipped to maintain consistency with the `cedar-wasm` package + +### Fixed + +- (*) JSON format Cedar policies will now fail to parse if any annotations are not + valid Cedar identifiers. (#1004, resolving #994) +- (*) `unknown()` is no longer a valid extension function if `partial-eval` + is not enabled as a feature. (#1101, resolving #1096) + +## [3.2.1] - 2024-05-31 +Cedar Language Version: 3.3 + +### Fixed + +- Fixed policy formatter dropping newlines in string literals. (#870, #910, resolving #862) +- Fixed a performance issue when constructing an error for accessing + a non-existent attribute on sufficiently large records (#887, resolving #754) +- Fixed identifier parsing in human-readable schemas (#914, resolving #913) +- Fixed the typescript generated type for `ffi::AuthorizationCall` to remove + unsupported string option (#939) +- Fixed Wasm build script to be multi-target in JS ecosystem (#933) + +## [3.2.0] - 2024-05-17 +Cedar Language Version: 3.3 + +### Added + +- `Expression::new_ip`, `Expression::new_decimal`, `RestrictedExpression::new_ip`, + and `RestrictedExpression::new_decimal` (#661, resolving #659) +- `Entities::into_iter` (#713, resolving #680) +- `Entity::into_inner` (#685, resolving #636) +- New `ffi` module with an improved FFI interface. This will replace the + `frontend` module in the 4.0 release, but is available now for early adopters; + the `frontend` module is now deprecated. + This should be considered a preview-release of `ffi`; more API breaking + changes are anticipated for Cedar 4.0. (#852) +- `wasm` Cargo feature for targeting Wasm (and the `cedar-wasm` crate was added + to this repo). + This should be considered a preview-release of `cedar-wasm`; more API + breaking changes are anticipated for Cedar 4.0. (#858) + +### Changed + +- Common type definitions in both human-readable and JSON schemas may now + reference other common type definitions. There may not be any cycles formed by + these references. (#766, resolving #154) +- Improved validation error messages when incompatible types appear in + `if`, `==`, `contains`, `containsAll`, and `containsAny` expressions. (#809, resolving #346) +- Deprecated error `TypeErrorKind::ImpossiblePolicy` in favor of warning + `ValidationWarningKind::ImpossiblePolicy` so future improvements to Cedar + typing precision will not result in breaking changes. (#716, resolving #539) +- Rework API for the `partial-eval` experimental feature (#714, #817, #838). +- Validation errors for unknown entity types and action entities now + report the precise source location where the unknown type was encountered. + Error for invalid use of an action now includes a source location containing + the offending policy. (#802, #808, resolving #522) +- Deprecated the `frontend` module in favor of the new `ffi` module. The + `frontend` module will be removed from `cedar-policy` in the next major version. + See notes above about `ffi`. (#852) +- Deprecated the integration testing harness code. It will be removed from the + `cedar-policy` crate in the next major version. (#707) + +### Fixed + +- Validation error message for an invalid attribute access now reports the + correct attribute and entity type when accessing an optional attribute that is + itself an entity. (#811) +- The error message returned when parsing an invalid action scope constraint + `action == ?action` no longer suggests that `action == [...]` would be a + valid scope constraint. (#818, resolving #563) +- Fixed policy formatter reordering some comments around if-then-else and + entity identifier expressions. (#861, resolving #787) + +## [3.1.4] - 2024-05-17 +Cedar Language Version: 3.2 + +### Fixed + +- The formatter will now fail with an error if it changes a policy's semantics. (#865) + +## [3.1.3] - 2024-04-15 +Cedar Language Version: 3.2 + +### Changed + +- Improve parser errors on unexpected tokens. (#698, partially resolving #176) +- Validation error messages render types in the new, more readable, schema + syntax. (#708, resolving #242) +- Improved error messages when `null` occurs in entity json data. (#751, + resolving #530) +- Improved source location reporting for error `found template slot in a when clause`. + (#758, resolving #736) +- Improved `Display` implementation for Cedar schemas, both JSON and human + syntax. (#780) + +### Fixed + +- Support identifiers in context declarations in the human-readable schema + format. (#734, resolving #681) + +## [3.1.2] - 2024-03-29 +Cedar Language Version: 3.2 + +### Changed + +- Implement [RFC 57](https://github.com/cedar-policy/rfcs/pull/57): policies can + now include multiplication of arbitrary expressions, not just multiplication of + an expression and a constant. + +## [3.1.1] - 2024-03-14 +Cedar Language Version: 3.1 + +### Fixed + +- `ValidationResult` methods `validation_errors` and `validation_warnings`, along with + `confusable_string_checker`, now return iterators with static lifetimes instead of + custom lifetimes, fixing build for latest nightly Rust. (#712) +- Validation for the `in` operator to no longer reports an error when comparing actions + in different namespaces. (#704, resolving #642) + +## [3.1.0] - 2024-03-08 +Cedar Language Version: 3.1 + +### Added + +- Implementation of the human-readable schema format proposed in + [RFC 24](https://github.com/cedar-policy/rfcs/blob/main/text/0024-schema-syntax.md). + New public APIs `SchemaFragment::from_*_natural`, + `SchemaFragment::as_natural`, and `Schema::from_*_natural` (#557) +- `PolicyId::new()` (#587, resolving #551) +- `EntityId::new()` (#583, resolving #553) +- `AsRef` implementation for `PolicyId` (#504, resolving #503) +- `Policy::template_links()` to retrieve the linked values for a + template-linked policy (#515, resolving #489) +- `AuthorizationError::id()` to get the id of the policy associated with an + authorization error (#589) +- For the `partial-eval` experimental feature: added + `Authorizer::evaluate_policies_partial()` (#593, resolving #474) +- For the `partial-eval` experimental feature: added + `json_is_authorized_partial()` (#571, resolving #570) + +### Changed + +- Better integration with `miette` for various error types. If you have + previously been just using the `Display` trait to get the error message from a + Cedar error type, you may want to consider also examining other data provided + by the `miette::Diagnostic` trait, for instance `.help()`. + Alternately, you can use `miette` and its `fancy` feature to format the error + and all associated information in a pretty human-readable format or as JSON. + For more details, see `miette`'s + [documentation](https://docs.rs/miette/latest/miette/index.html). (#477) +- Cedar reserved words like `if`, `has`, and `true` are now allowed as policy + annotation keys. (#634, resolving #623) +- Add hints suggesting how to fix some type errors. (#513) +- The `ValidationResult` returned from `Validator::validate` now has a static + lifetime, allowing it to be used in more contexts. The lifetime parameter + will be removed in a future major version. (#512) +- Improve parse error around invalid `is` expressions. (#491, resolving #409) +- Improve parse error message when a policy includes an invalid template slot. + The error now identifies that the policy used an invalid slot and suggests using + one of the valid slots. (#487, resolving #451) +- Improve parse error messages to more reliably notice that a function or + method does exist when it is called with an incorrect number of arguments or + using the wrong call style. (#482) +- Include source spans on more parse error messages. (#471, resolving #465) +- Include source spans on more evaluation error messages. (#582) +- Changed error message on `SchemaError::UndeclaredCommonTypes` to report + fully qualified type names. (#652, resolving #580) +- For the `partial-eval` experimental feature: make the return values of + `RequestBuilder`'s `principal`, `action`, `resource`, `context` and + `schema` functions `#[must_use]`. (#502) +- For the `partial-eval` experimental feature: make `RequestBuilder::schema` + return a `RequestBuilder<&Schema>` so the `RequestBuilder<&Schema>::build` + method checks the request against the schema provided and the + `RequestBuilder::build` method becomes infallible. (#591, + resolving #559) +- For the `permissive-validate` experimental feature: `X in []` is typed `False` + for all `X`, including unspecified `X`. (#615) + +### Fixed + +- Action entities in the store will pass schema-based validation without requiring + the transitive closure to be pre-computed. (#581, resolving #285) +- Variables qualified by a namespace with a single element are correctly + rejected. E.g., `foo::principal` is an error and is not parsed as + `principal`. Variables qualified by a namespace of any size comprised entirely + of Cedar keywords are correctly rejected. E.g., `if::then::else::principal` is + an error. (#594 and #597) +- The entity type tested for by an `is` expression may be an identifier shared + with a builtin variable. E.g., `... is principal` and `... is action` are now + accepted by the Cedar parser. (#595, resolving #558) +- Policies containing the literal `i64::MIN` can now be properly converted to + the JSON policy format. (#601, resolving #596) +- `Policy::to_json` does not error on policies containing special identifiers + such as `principal`, `then`, and `true`. (#628, resolving #604) +- `Template::from_json` errors when there are slots in template conditions. + (#626, resolving #606) + +## [3.0.1] - 2023-12-21 +Cedar Language Version: 3.0 + +### Fixed + +- Possible panic (when stack size limit reached) in `Context::empty()` (#524, + fixed by #526) + +## [3.0.0] - 2023-12-15 +Cedar Language Version: 3.0 + +### Added + +- The `is` operation as described in + [RFC 5](https://github.com/cedar-policy/rfcs/blob/main/text/0005-is-operator.md). + (#396) +- Marked the `Template::from_json` and `Template::to_json` apis as public (#458) +- New APIs to `Entities` to make it easy to add a collection of entities to an + existing `Entities` structure. (#276) +- `PolicySet::remove_static`, `PolicySet::remove_template` and + `PolicySet::unlink` to remove policies from the policy set. (#337, resolving #328) +- `PolicySet::get_linked_policies` to get the policies linked to a `Template`. (#337) +- Export the `cedar_policy_core::evaluator::{EvaluationError, EvaluationErrorKind}` and + `cedar_policy_core::authorizer::AuthorizationError` error types. (#260, #271) +- `ParseError::primary_source_span` to get the primary source span locating an + error. (#324) +- `ValidationResult::validation_warnings` to access non-fatal warnings returned + by the validator and `ValidationResult::validation_passed_without_warnings`. + The main validation entry point now checks for warnings previously only + available through `confusable_string_checker`. (#404) +- `Entity::new_no_attrs()` which provides an infallible constructor for `Entity` + in the case that there are no attributes. (See changes to `Entity::new()` + below.) (#430) +- `RestrictedExpression::new_entity_uid()` (#442, resolving #350) +- Experimental API `PolicySet::unknown_entities` to collect unknown entity UIDs + from a `PartialResponse`. (#353, resolving #321) + +### Changed + +- Implement [RFC 19](https://github.com/cedar-policy/rfcs/blob/main/text/0019-stricter-validation.md), + making validation slightly more strict, but more explainable. (#282) +- Implement [RFC 20](https://github.com/cedar-policy/rfcs/blob/main/text/0020-unique-record-keys.md), + disallowing duplicate keys in record values (including record literals in + policies, request `context`, and records in entity attributes). (#375) +- `Request::new()` now takes an optional schema argument, and validates the request + against that schema. To signal validation errors, it now returns a `Result`. + (#393, resolving #191) +- `Entities::from_*()` methods now automatically add action entities present in + the `schema` to the constructed `Entities`, if a `schema` is provided. (#360) +- `Entities::from_*()` methods now validate the entities against the `schema`, + if a `schema` is provided. (#360) +- `Entities::from_entities()` and `Entities::add_entities()` now take an + optional schema argument. (#360) +- `Diagnostics::errors()` now returns an iterator over `AuthorizationError`s. + (#260) +- `Response::new()` now expects a `Vec` as its third + argument. (#260) +- Change the semantics of equality for IP ranges. For example, + `ip("192.168.0.1/24") == ip("192.168.0.3/24")` was previously `true` and is now + `false`. The behavior of equality on single IP addresses is unchanged, and so is + the behavior of `.isInRange()`. (#348) +- Standardize on duplicates being errors instead of last-write-wins in the + JSON-based APIs in the `frontend` module. This also means some error types + have changed. (#365, #448) +- `Entity::new()` now eagerly evaluates entity attributes, leading to + performance improvements (particularly when entity data is reused across + multiple `is_authorized` calls). As a result, it returns `Result`, because + attribute evaluation can fail. (#430) +- `Entities::from_json_*()` also now eagerly evaluates entity attributes, and as + a result returns errors when attribute evaluation fails. (#430) +- `Entity::attr()` now returns errors in many fewer cases (because the attribute + is stored in already-evaluated form), and its error type has changed. (#430) +- `Context::from_*()` methods also now eagerly evaluate the `Context`, and as + a result return errors when evaluation fails. (#430) +- Rename `cedar_policy_core::est::EstToAstError` to + `cedar_policy_core::est::FromJsonError`. (#197) +- Rename `cedar_policy_core::entities::JsonDeserializationError::ExtensionsError` + to `cedar_policy_core::entities::JsonDeserializationError::ExtensionFunctionLookup`. + (#360) +- Rename variants in `SchemaError`. (#231) +- `SchemaError` has a new variant corresponding to errors evaluating action + attributes. (#430) +- Improve schema parsing error messages when a cycle exists in the action + hierarchy to includes an action which is part of the cycle (#436, resolving + #416). +- `::Error` is now `Infallible` instead of `ParseErrors`. + (#372) +- Improve the `Display` impls for `Policy` and `PolicySet`, and add a `Display` + impl for `Template`. The displayed representations now more closely match the + original input, whether the input was in string or JSON form. (#167, resolving + #125) +- `ValidationWarning::location` and `ValidationWarning::to_kind_and_location` + now return `&SourceLocation<'a>` instead of `&'a PolicyID`, matching + `ValidationError::location`. (#405) +- `ValidationWarningKind` is now `non_exhaustive`, allowing future warnings to + be added without a breaking change. (#404) + +### Fixed + +- Evaluation order of operand to `>` and `>=`. They now evaluate left to right, + matching all other operators. This affects what error is reported when there is + an evaluation error in both operands, but does not otherwise change the result + of evaluation. (#402, resolving #112) +- Updated `PolicySet::link` to not mutate internal state when failing to link a static + policy. With this fix it is possible to create a link with a policy id + after previously failing to create that link with the same id from a static + policy. (#412) +- Fixed schema-based parsing of entity data that includes unknowns (for the + `partial-eval` experimental feature). (#419, resolving #418) + +### Removed + +- Removed `__expr` escape from Cedar JSON formats, which has been deprecated + since Cedar 1.2. (#333) +- Move `ValidationMode::Permissive` behind an experimental feature flag. + To continue using this feature you must enable the `permissive-validate` + feature flag. (#428) + +## [2.5.0] - 2024-09-16 +Cedar Language Version: 2.2 + +### Added + +- Convenience methods `num_of_policies()` and `num_of_templates()` to see how + many policies and templates a policy set has (#1180) + +## [2.4.7] - 2024-05-31 +Cedar Language Version: 2.2 + +### Fixed + +- Fixed policy formatter reordering some comments around if-then-else and + entity identifier expressions. (#861, resolving #787) +- Fixed policy formatter dropping newlines in string literals. (#870, #910, resolving #862) + +## [2.4.6] - 2024-05-17 +Cedar Language Version: 2.2 + +### Fixed + +- The formatter will now fail with an error if it changes a policy's semantics. (#865) + +## [2.4.5] - 2024-04-01 +Cedar Language Version: 2.2 + +### Changed + +- Implement [RFC 57](https://github.com/cedar-policy/rfcs/pull/57): policies can + now include multiplication of arbitrary expressions, not just multiplication of + an expression and a constant. + +## [2.4.4] - 2024-03-08 +Cedar Language Version: 2.1 + +### Changed + +- Calling `add_template` with a `PolicyId` that is an existing link will now error. (#671, backport of #456) + +### Fixed + +- Updated `PolicySet::link` to not mutate internal state when failing to link a static + policy. With this fix it is possible to create a link with a policy id + after previously failing to create that link with the same id from a static + policy. (#669, backport of #412) +- Action entities in the store will pass schema-based validation without requiring + the transitive closure to be pre-computed. (#688, backport of #581) +- Policies containing the literal `i64::MIN` can now be properly converted to the JSON policy format. (#672, backport of #601) +- `Template::from_json` errors when there are slots in template conditions. (#672, backport of #626) +- `Policy::to_json` does not error on policies containing special identifiers such as `principal`, `then`, and `true`. (#672, backport of #628) + +## [2.4.3] - 2023-12-21 +Cedar Language Version: 2.1 + +### Fixed + +- Reverted accidental breaking change to schema format introduced in the 2.3.2 + release. + Attribute types in schema files may now contain unexpected keys (as they could + before 2.3.2). + As a side effect, schema parsing error messages are less useful when an + attribute type is missing a required key. + The 2.4.2 behavior, including the more useful error messages, remain available + in all 3.x versions of Cedar. + (#520) + +## [2.4.2] - 2023-10-23 +Cedar Language Version: 2.1 + +### Fixed + +- Issue #370 related to how the validator handles template-linked policies. + The validator will now produce the same result for an equivalent static + and template-linked policy. (#371, resolving #370) + +## [2.4.1] - 2023-10-12 +Cedar Language Version: 2.1 + +### Added + +- Experimental API to construct queries with `Unknown` fields for partial evaluation. + +### Changed + +- Improve validation error messages for access to undeclared attributes and + unsafe access to optional attributes to report the target of the access. (#295) +- `EntityUid`'s impl of `FromStr` is no longer marked as deprecated. (#319) + +### Fixed + +- Issue #299 related to how partial evaluation handled conditions of `if`, + resulting in a panic on some inputs. +- `Request::principal()`, `Request::action()`, and `Request::resource()` will + now return `None` if the entities are unspecified (i.e., constructed by passing + `None` to `Request::new()`). (#339) + +## [2.4.0] - 2023-09-21 +Cedar Language Version: 2.1 + +### Added + +- New methods for `EntityTypeName`. + - `basename` to get the basename (without namespaces). + - `namespace_components` to get the namespace as an iterator over its components. + - `namespace` to get the namespace as a single string. + +### Changed + +- Some error types now carry more information about the error, with error + messages updated appropriately. For instance, the `RecordAttrDoesNotExist` error + message now contains a list of attributes that _do_ exist. +- Improve error messages for some schema parsing errors. + - When an entity type shape or action context is declared with type other than + `Record`, the error message will indicated the affected entity type or action. +- Various other improvements to error messages and documentation for errors raised during + policy parsing, validation, and evaluation. +- Increase precision for validating records. Previously, + `permit(principal, action, resource) when {{"foo": 5} has bar};` would validate. + Now it will not, since we know `{"foo": 5} has bar` is `False`, and the + validator will return an error for a policy that can never fire. + +### Removed + +- Uses of deprecated `__expr` escapes from integration tests. + +## [2.3.3] - 2023-08-29 +Cedar Language Version: 2.1 + +### Added + +- Re-export `cedar_policy_core::entities::EntitiesError`. + +### Changed + +- Improve error messages and documentation for some errors raised during + policy parsing, validation, and evaluation. +- More precise "expected tokens" lists in some parse errors. + +### Fixed + +- Issue #150 related to implicit namespaces for actions in `memberOf` lists in + schemas. An action without an explicit namespace in a `memberOf` now + correctly uses the default namespace. (#151) + +## [2.3.2] - 2023-08-04 +Cedar Language Version: 2.1 + +### Changed + +- Improve error messages for some validation errors +- Improve error messages for some schema parsing errors. + - Parsing a schema type without the `"type"` field will generate an error + stating that `"type"` is a required field instead of an inscrutable error + complaining about the untagged enum `SchemaType`. + - Parsing a schema type with a `"type"` field corresponding to one of the + builtin types but missing a required field for that type will generate an + error stating that a required field is missing instead of claiming that it + could not find "common types" definition for that builtin type. + +### Fixed + +- Issues #73 and #74 related to schema-based parsing. + - Detect entities with parents of an incorrect entity type. + - Detect entities with an undeclared entity type. + +### Removed + +- Move public API for partial evaluation behind experimental feature flag. To + continue using this feature you must enable the `partial-eval` feature flag. + +## [2.3.1] - 2023-07-20 +Cedar Language Version: 2.1 + +### Fixed + +- Panic in `PolicySet::link()` that could occur when the function was called + with a policy id corresponding to a static policy. (#203) + +## [2.3.0] - 2023-06-29 +Cedar Language Version: 2.1 + +### Changed + +- Implement +[RFC 9](https://github.com/cedar-policy/rfcs/blob/main/text/0009-disallow-whitespace-in-entityuid.md) +which disallows embedded whitespace, comments, and control characters in the +inputs to several Rust API functions including `EntityTypeName::from_str()` and +`EntityNamespace::from_str()`, as well as in some fields of the Cedar JSON +schema format (e.g., namespace declarations, entity type names), Cedar JSON +entities format (e.g., entity type names, extension function names) and the +Cedar JSON policy format used by `Policy::from_json()` (e.g., entity type names, +extension function names). The risk that this may be a breaking change for some +Cedar users was accepted due to the potential security ramifications; see +discussion in the RFC. + +## 2.2.0 - 2023-05-25 +Cedar Language Version: 2.0 + +### Added + +- `Entities::write_to_json` function to api.rs. + +## 2.1.0 - 2023-05-23 +Cedar Language Version: 2.0 + +### Added + +- `Schema::action_entities` to provide access to action entities defined in a schema. + +### Changed + +- Update `cedar-policy-core` dependency. + +### Fixed + +- Resolve warning in `Cargo.toml` due to having both `license` and `license-file` metadata entries. + +## 2.0.3 - 2023-05-17 +Cedar Language Version: 2.0 + +### Fixed + +- Update `Cargo.toml` metadata to correctly represent this crate as Apache-2.0 licensed. + +## 2.0.2 - 2023-05-10 +Cedar Language Version: 2.0 + +## 2.0.1 - 2023-05-10 +Cedar Language Version: 2.0 + +## 2.0.0 - 2023-05-10 +Cedar Language Version: 2.0 +- Initial release of `cedar-policy`. + +[Unreleased]: https://github.com/cedar-policy/cedar/compare/v4.0.0...main +[4.0.0]: https://github.com/cedar-policy/cedar/compare/v3.4.0...v4.0.0 +[3.4.0]: https://github.com/cedar-policy/cedar/compare/v3.3.0...v3.4.0 +[3.3.0]: https://github.com/cedar-policy/cedar/compare/v3.2.4...v3.3.0 +[3.2.4]: https://github.com/cedar-policy/cedar/compare/v3.2.1...v3.2.4 +[3.2.1]: https://github.com/cedar-policy/cedar/compare/v3.2.0...v3.2.1 +[3.2.0]: https://github.com/cedar-policy/cedar/compare/v3.1.4...v3.2.0 +[3.1.4]: https://github.com/cedar-policy/cedar/compare/v3.1.3...v3.1.4 +[3.1.3]: https://github.com/cedar-policy/cedar/compare/v3.1.2...v3.1.3 +[3.1.2]: https://github.com/cedar-policy/cedar/compare/v3.1.1...v3.1.2 +[3.1.1]: https://github.com/cedar-policy/cedar/compare/v3.1.0...v3.1.1 +[3.1.0]: https://github.com/cedar-policy/cedar/compare/v3.0.1...v3.1.0 +[3.0.1]: https://github.com/cedar-policy/cedar/compare/v3.0.0...v3.0.1 +[3.0.0]: https://github.com/cedar-policy/cedar/compare/v2.5.0...v3.0.0 +[2.5.0]: https://github.com/cedar-policy/cedar/compare/v2.4.7...v2.5.0 +[2.4.7]: https://github.com/cedar-policy/cedar/compare/v2.4.6...v2.4.7 +[2.4.6]: https://github.com/cedar-policy/cedar/compare/v2.4.5...v2.4.6 +[2.4.5]: https://github.com/cedar-policy/cedar/compare/v2.4.4...v2.4.5 +[2.4.4]: https://github.com/cedar-policy/cedar/compare/v2.4.3...v2.4.4 +[2.4.3]: https://github.com/cedar-policy/cedar/compare/v2.4.2...v2.4.3 +[2.4.2]: https://github.com/cedar-policy/cedar/compare/v2.4.1...v2.4.2 +[2.4.1]: https://github.com/cedar-policy/cedar/compare/v2.4.0...v2.4.1 +[2.4.0]: https://github.com/cedar-policy/cedar/compare/v2.3.3...v2.4.0 +[2.3.3]: https://github.com/cedar-policy/cedar/compare/v2.3.2...v2.3.3 +[2.3.2]: https://github.com/cedar-policy/cedar/compare/v2.3.1...v2.3.2 +[2.3.1]: https://github.com/cedar-policy/cedar/compare/v2.3.0...v2.3.1 +[2.3.0]: https://github.com/cedar-policy/cedar/releases/tag/v2.3.0 +>>>>>>> cc818918 (add `getTag` and `hasTag` operations to AST, EST, policy parser, and evaluator (#1207)) diff --git a/cedar-policy/Cargo.toml b/cedar-policy/Cargo.toml index 81dc63d569..6f3111b0c3 100644 --- a/cedar-policy/Cargo.toml +++ b/cedar-policy/Cargo.toml @@ -21,7 +21,7 @@ lalrpop-util = { version = "0.21.0", features = ["lexer"] } itertools = "0.13" miette = "7.1.0" thiserror = "1.0" -smol_str = { version = "0.2", features = ["serde"] } +smol_str = { version = "0.3", features = ["serde"] } dhat = { version = "0.3.2", optional = true } serde_with = "3.3.0" nonempty = "0.10" @@ -30,6 +30,8 @@ nonempty = "0.10" serde-wasm-bindgen = { version = "0.6", optional = true } tsify = { version = "0.4.5", optional = true } wasm-bindgen = { version = "0.2.82", optional = true } +semver = "1.0.23" +lazy_static = "1.5.0" [features] # by default, enable all Cedar extensions, but not other crate features @@ -45,7 +47,8 @@ corpus-timing = [] # Experimental features. # Enable all experimental features with `cargo build --features "experimental"` -experimental = ["partial-eval", "permissive-validate", "partial-validate"] +experimental = ["partial-eval", "permissive-validate", "partial-validate", "entity-tags"] +entity-tags = ["cedar-policy-core/entity-tags", "cedar-policy-validator/entity-tags"] partial-eval = ["cedar-policy-core/partial-eval", "cedar-policy-validator/partial-eval"] permissive-validate = [] partial-validate = ["cedar-policy-validator/partial-validate"] diff --git a/cedar-policy/src/api.rs b/cedar-policy/src/api.rs index 2e4cab2fb3..d3d814ae76 100644 --- a/cedar-policy/src/api.rs +++ b/cedar-policy/src/api.rs @@ -52,13 +52,36 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::io::Read; use std::str::FromStr; +// PANIC SAFETY: `CARGO_PKG_VERSION` should return a valid SemVer version string +#[allow(clippy::unwrap_used)] +pub(crate) mod version { + use lazy_static::lazy_static; + use semver::Version; + + lazy_static! { + // Cedar Rust SDK Semantic Versioning version + static ref SDK_VERSION: Version = env!("CARGO_PKG_VERSION").parse().unwrap(); + // Cedar language version + // The patch version field may be unnecessary + static ref LANG_VERSION: Version = Version::new(4, 0, 0); + } + /// Get the Cedar SDK Semantic Versioning version + pub fn get_sdk_version() -> Version { + SDK_VERSION.clone() + } + /// Get the Cedar language version + pub fn get_lang_version() -> Version { + LANG_VERSION.clone() + } +} + /// Entity datatype #[repr(transparent)] #[derive(Debug, Clone, PartialEq, Eq, RefCast, Hash)] pub struct Entity(ast::Entity); impl Entity { - /// Create a new `Entity` with this Uid, attributes, and parents. + /// Create a new `Entity` with this Uid, attributes, and parents (and no tags). /// /// Attribute values are specified here as "restricted expressions". /// See docs on `RestrictedExpression` @@ -88,15 +111,24 @@ impl Entity { // the `Entities` object is created Ok(Self(ast::Entity::new( uid.into(), - attrs - .into_iter() - .map(|(k, v)| (SmolStr::from(k), v.0)) - .collect(), + attrs.into_iter().map(|(k, v)| (SmolStr::from(k), v.0)), parents.into_iter().map(EntityUid::into).collect(), + #[cfg(feature = "entity-tags")] + [], Extensions::all_available(), )?)) } + /// Create a new `Entity` with this Uid, parents, and no attributes or tags. + /// This is the same as `Self::new` except the attributes are empty, and therefore it can + /// return `Self` instead of `Result` + pub fn new_empty_attrs(uid: EntityUid, parents: HashSet) -> Self { + Self(ast::Entity::new_empty_attrs( + uid.into(), + parents.into_iter().map(EntityUid::into).collect(), + )) + } + /// Create a new `Entity` with no attributes. /// /// Unlike [`Entity::new()`], this constructor cannot error. @@ -106,7 +138,7 @@ impl Entity { // the `Entities` object is created Self(ast::Entity::new_with_attr_partial_value( uid.into(), - HashMap::new(), + [], parents.into_iter().map(EntityUid::into).collect(), )) } @@ -175,7 +207,7 @@ impl Entity { HashMap, HashSet, ) { - let (uid, attrs, ancestors) = self.0.into_inner(); + let (uid, attrs, ancestors, _) = self.0.into_inner(); let attrs = attrs .into_iter() @@ -2903,6 +2935,23 @@ impl Policy { get_valid_request_envs(self.ast.template(), s) } + /// Get all entity literals occuring in a `Policy` + pub fn entity_literals(&self) -> Vec { + self.ast + .condition() + .subexpressions() + .filter_map(|e| match e.expr_kind() { + cedar_policy_core::ast::ExprKind::Lit(l) => match l { + cedar_policy_core::ast::Literal::EntityUID(euid) => { + Some(EntityUid((*euid).as_ref().clone())) + } + _ => None, + }, + _ => None, + }) + .collect() + } + fn from_est(id: Option, est: est::Policy) -> Result { Ok(Self { ast: est.clone().try_into_ast_policy(id.map(PolicyId::into))?, diff --git a/cedar-policy/src/api/err.rs b/cedar-policy/src/api/err.rs index fcfca69f31..a5dfefa0e5 100644 --- a/cedar-policy/src/api/err.rs +++ b/cedar-policy/src/api/err.rs @@ -54,7 +54,7 @@ pub mod conformance_errors { pub use cedar_policy_core::entities::conformance::err::{ ActionDeclarationMismatch, EntitySchemaConformanceError, ExtensionFunctionLookup, InvalidAncestorType, MissingRequiredEntityAttr, TypeMismatch, UndeclaredAction, - UnexpectedEntityAttr, UnexpectedEntityTypeError, + UnexpectedEntityAttr, UnexpectedEntityTag, UnexpectedEntityTypeError, }; } @@ -247,14 +247,16 @@ impl From for CedarSchemaError { } } -/// Error when evaluating an entity attribute +/// Error when evaluating an entity attribute or tag #[derive(Debug, Diagnostic, Error)] -#[error("in attribute `{attr}` of `{uid}`: {err}")] +#[error("in {} `{attr_or_tag}` of `{uid}`: {err}", if *.was_attr { "attribute" } else { "tag" })] pub struct EntityAttrEvaluationError { - /// Action that had the attribute with the error + /// Action that had the attribute or tag with the error uid: EntityUid, - /// Attribute that had the error - attr: SmolStr, + /// Attribute or tag that had the error + attr_or_tag: SmolStr, + /// Is `attr_or_tag` an attribute (`true`) or a tag (`false`) + was_attr: bool, /// Underlying evaluation error #[diagnostic(transparent)] err: EvaluationError, @@ -266,9 +268,11 @@ impl EntityAttrEvaluationError { &self.uid } - /// Get the name of the attribute that had the error + /// Get the name of the attribute or tag that had the error + // + // Method is named `.attr()` and not `.attr_or_tag()` for historical / backwards-compatibility reasons pub fn attr(&self) -> &SmolStr { - &self.attr + &self.attr_or_tag } /// Get the underlying evaluation error @@ -282,7 +286,8 @@ impl From for EntityAttrEvaluationError { fn from(err: ast::EntityAttrEvaluationError) -> Self { Self { uid: err.uid.into(), - attr: err.attr, + attr_or_tag: err.attr_or_tag, + was_attr: err.was_attr, err: err.err, } } @@ -366,6 +371,16 @@ pub enum ValidationError { #[error(transparent)] #[diagnostic(transparent)] UnsafeOptionalAttributeAccess(#[from] validation_errors::UnsafeOptionalAttributeAccess), + /// The typechecker could not conclude that an access to a tag was safe. + #[error(transparent)] + #[diagnostic(transparent)] + #[cfg(feature = "entity-tags")] + UnsafeTagAccess(#[from] validation_errors::UnsafeTagAccess), + /// `.getTag()` on an entity type which cannot have tags according to the schema. + #[error(transparent)] + #[diagnostic(transparent)] + #[cfg(feature = "entity-tags")] + NoTagsAllowed(#[from] validation_errors::NoTagsAllowed), /// Undefined extension function. #[error(transparent)] #[diagnostic(transparent)] @@ -392,6 +407,11 @@ pub enum ValidationError { #[error(transparent)] #[diagnostic(transparent)] HierarchyNotRespected(#[from] validation_errors::HierarchyNotRespected), + /// Returned when an internal invariant is violated (should not happen; if + /// this is ever returned, please file an issue) + #[error(transparent)] + #[diagnostic(transparent)] + InternalInvariantViolation(#[from] validation_errors::InternalInvariantViolation), } impl ValidationError { @@ -405,12 +425,17 @@ impl ValidationError { Self::IncompatibleTypes(e) => e.policy_id(), Self::UnsafeAttributeAccess(e) => e.policy_id(), Self::UnsafeOptionalAttributeAccess(e) => e.policy_id(), + #[cfg(feature = "entity-tags")] + Self::UnsafeTagAccess(e) => e.policy_id(), + #[cfg(feature = "entity-tags")] + Self::NoTagsAllowed(e) => e.policy_id(), Self::UndefinedFunction(e) => e.policy_id(), Self::WrongNumberArguments(e) => e.policy_id(), Self::FunctionArgumentValidation(e) => e.policy_id(), Self::EmptySetForbidden(e) => e.policy_id(), Self::NonLitExtConstructor(e) => e.policy_id(), Self::HierarchyNotRespected(e) => e.policy_id(), + Self::InternalInvariantViolation(e) => e.policy_id(), } } } @@ -440,6 +465,14 @@ impl From for ValidationError { cedar_policy_validator::ValidationError::UnsafeOptionalAttributeAccess(e) => { Self::UnsafeOptionalAttributeAccess(e.into()) } + #[cfg(feature = "entity-tags")] + cedar_policy_validator::ValidationError::UnsafeTagAccess(e) => { + Self::UnsafeTagAccess(e.into()) + } + #[cfg(feature = "entity-tags")] + cedar_policy_validator::ValidationError::NoTagsAllowed(e) => { + Self::NoTagsAllowed(e.into()) + } cedar_policy_validator::ValidationError::UndefinedFunction(e) => { Self::UndefinedFunction(e.into()) } @@ -458,6 +491,9 @@ impl From for ValidationError { cedar_policy_validator::ValidationError::HierarchyNotRespected(e) => { Self::HierarchyNotRespected(e.into()) } + cedar_policy_validator::ValidationError::InternalInvariantViolation(e) => { + Self::InternalInvariantViolation(e.into()) + } } } } diff --git a/cedar-policy/src/api/err/validation_errors.rs b/cedar-policy/src/api/err/validation_errors.rs index 91e1528a69..0e443b5cec 100644 --- a/cedar-policy/src/api/err/validation_errors.rs +++ b/cedar-policy/src/api/err/validation_errors.rs @@ -63,9 +63,14 @@ wrap_core_error!(UnexpectedType); wrap_core_error!(IncompatibleTypes); wrap_core_error!(UnsafeAttributeAccess); wrap_core_error!(UnsafeOptionalAttributeAccess); +#[cfg(feature = "entity-tags")] +wrap_core_error!(UnsafeTagAccess); +#[cfg(feature = "entity-tags")] +wrap_core_error!(NoTagsAllowed); wrap_core_error!(UndefinedFunction); wrap_core_error!(WrongNumberArguments); wrap_core_error!(FunctionArgumentValidation); wrap_core_error!(HierarchyNotRespected); wrap_core_error!(EmptySetForbidden); wrap_core_error!(NonLitExtConstructor); +wrap_core_error!(InternalInvariantViolation); diff --git a/cedar-policy/src/ffi/format.rs b/cedar-policy/src/ffi/format.rs index 2d5c629b14..b4d2c0c135 100644 --- a/cedar-policy/src/ffi/format.rs +++ b/cedar-policy/src/ffi/format.rs @@ -152,7 +152,7 @@ mod test { }); let result = assert_format_succeeds(json); - assert_eq!(result, "permit (\n principal in UserGroup::\"alice_friends\",\n action == Action::\"viewPhoto\",\n resource\n);"); + assert_eq!(result, "permit (\n principal in UserGroup::\"alice_friends\",\n action == Action::\"viewPhoto\",\n resource\n);\n"); } #[test] @@ -162,7 +162,7 @@ mod test { }); let result = assert_format_succeeds(json); - assert_eq!(result, "permit (\n principal in UserGroup::\"alice_friends\",\n action == Action::\"viewPhoto\",\n resource\n);"); + assert_eq!(result, "permit (\n principal in UserGroup::\"alice_friends\",\n action == Action::\"viewPhoto\",\n resource\n);\n"); } #[test] diff --git a/cedar-policy/src/lib.rs b/cedar-policy/src/lib.rs index cc4628f3f6..97ae1719a7 100644 --- a/cedar-policy/src/lib.rs +++ b/cedar-policy/src/lib.rs @@ -46,6 +46,7 @@ /// Rust public API mod api; +pub use api::version::{get_lang_version, get_sdk_version}; pub use api::*; /// FFI utilities, see comments in the module itself diff --git a/cedar-policy/src/tests.rs b/cedar-policy/src/tests.rs index 854dffcd8f..8dbcdbf9b6 100644 --- a/cedar-policy/src/tests.rs +++ b/cedar-policy/src/tests.rs @@ -5715,3 +5715,289 @@ mod context_tests { ); } } + +mod policy_manipulation_functions_tests { + use super::*; + + #[test] + fn empty_policy() { + let policy_str = r###"permit(principal, action, resource); + "###; + let policy = Policy::from_str(policy_str).expect("should succeed"); + assert_eq!(policy.entity_literals(), vec![]); + } + + #[test] + fn non_empty_policy() { + let policy_str = r###"permit(principal == User::"Bob", action == Action::"view", resource) when { + !resource.private && resource.owner != User::"Alice" + }; + "###; + let policy = Policy::from_str(policy_str).expect("should succeed"); + let res = policy.entity_literals(); + assert_eq!(res.len(), 3); + assert!(res.contains(&EntityUid::from_str("User::\"Bob\"").expect("should parse"))); + assert!(res.contains(&EntityUid::from_str("Action::\"view\"").expect("should parse"))); + assert!(res.contains(&EntityUid::from_str("User::\"Alice\"").expect("should parse"))); + } +} + +mod version_tests { + use crate::{get_lang_version, get_sdk_version}; + + #[test] + fn test_sdk_version() { + assert_eq!(get_sdk_version().to_string(), "4.0.0"); + } + + #[test] + fn test_lang_version() { + assert_eq!(get_lang_version().to_string(), "4.0.0"); + } +} + +mod reserved_keywords_in_policies { + use super::*; + use cool_asserts::assert_matches; + + const RESERVED_IDENTS: [&str; 9] = [ + "true", "false", "if", "then", "else", "in", "like", "has", "is", + ]; + const RESERVED_NAMESPACE: [&str; 1] = ["__cedar"]; + const OTHER_SPECIAL_IDENTS: [&str; 8] = [ + "principal", + "action", + "resource", + "context", + "permit", + "forbid", + "when", + "unless", + ]; + + const RESERVED_IDENT_MSG: fn(&str) -> String = + |id| format!("this identifier is reserved and cannot be used: {id}"); + const RESERVED_NAMESPACE_MSG: fn(&str) -> String = + |name| format!("The name `{name}` contains `__cedar`, which is reserved"); + + #[track_caller] + fn assert_valid_annotation(id: &str) { + let res = Policy::from_str(&format!( + r#" + @{id}("foo") + permit(principal, action, resource); + "# + )); + assert_matches!(res, Ok(_)) + } + + #[track_caller] + fn assert_valid_expression(src: String) { + assert_matches!(Expression::from_str(&src), Ok(_)); + } + + #[track_caller] + fn assert_invalid_expression(src: String, error: String, underline: String) { + let expected_err = ExpectedErrorMessageBuilder::error(&error) + .exactly_one_underline(&underline) + .build(); + assert_matches!(Expression::from_str(&src), Err(err) => expect_err(&*src, &Report::new(err), &expected_err)); + } + + #[track_caller] + fn assert_invalid_expression_with_help( + src: String, + error: String, + underline: String, + help: String, + ) { + let expected_err = ExpectedErrorMessageBuilder::error(&error) + .exactly_one_underline(&underline) + .help(&help) + .build(); + assert_matches!(Expression::from_str(&src), Err(err) => expect_err(&*src, &Report::new(err), &expected_err)); + } + + #[test] + fn test_reserved_annotations() { + // Currently, any identifier can be used as an annotation key + RESERVED_IDENTS + .iter() + .chain(RESERVED_NAMESPACE.iter()) + .chain(OTHER_SPECIAL_IDENTS.iter()) + .for_each(|id| assert_valid_annotation(id)); + } + + #[test] + fn test_reserved_keys() { + // Any ident can be used as a record key if it's wrapped in quotes + RESERVED_IDENTS + .iter() + .chain(RESERVED_NAMESPACE.iter()) + .chain(OTHER_SPECIAL_IDENTS.iter()) + .for_each(|id| { + assert_valid_expression(format!("{{ \"{id}\": 1 }}")); + assert_valid_expression(format!("principal has \"{id}\"")); + assert_valid_expression(format!("principal[\"{id}\"] == \"foo\"")); + }); + + // No restrictions on OTHER_SPECIAL_IDENTS + OTHER_SPECIAL_IDENTS.iter().for_each(|id| { + assert_valid_expression(format!("{{ {id}: 1 }}")); + assert_valid_expression(format!("principal has {id}")); + assert_valid_expression(format!("principal.{id} == \"foo\"")); + }); + + // RESERVED_IDENTS cannot be used as keys without quotes + RESERVED_IDENTS.into_iter().for_each(|id| { + // slightly different errors depending on `id`; related to #407 + match id { + "true" | "false" => { + assert_invalid_expression_with_help( + format!("{{ {id}: 1 }}"), + format!("invalid attribute name: {id}"), + id.into(), + "attribute names can either be identifiers or string literals".into(), + ); + assert_invalid_expression_with_help( + format!("principal has {id}"), + format!("invalid attribute name: {id}"), + id.into(), + "attribute names can either be identifiers or string literals".into(), + ); + } + "if" => { + assert_invalid_expression( + format!("{{ {id}: 1 }}"), + RESERVED_IDENT_MSG(id), + format!("{id}: 1"), + ); + assert_invalid_expression( + format!("principal has {id}"), + RESERVED_IDENT_MSG(id), + format!("principal has {id}"), + ); + } + _ => { + assert_invalid_expression( + format!("{{ {id}: 1 }}"), + RESERVED_IDENT_MSG(id), + id.into(), + ); + assert_invalid_expression( + format!("principal has {id}"), + RESERVED_IDENT_MSG(id), + id.into(), + ); + } + } + // this case leads to a consistent error for all keywords + assert_invalid_expression( + format!("principal.{id} == \"foo\""), + RESERVED_IDENT_MSG(id), + id.into(), + ); + }); + + // RESERVED_NAMESPACE cannot be used as keys without quotes + RESERVED_NAMESPACE.into_iter().for_each(|id| { + assert_invalid_expression( + format!("{{ {id}: 1 }}"), + RESERVED_NAMESPACE_MSG(id), + id.into(), + ); + assert_invalid_expression( + format!("principal has {id}"), + RESERVED_NAMESPACE_MSG(id), + id.into(), + ); + assert_invalid_expression( + format!("principal.{id} == \"foo\""), + RESERVED_NAMESPACE_MSG(id), + "princip".into(), // TODO(#1221): wrong source is used + ); + }); + } + + #[test] + fn test_reserved_namespace_elements() { + // No restrictions on OTHER_SPECIAL_IDENTS + OTHER_SPECIAL_IDENTS.iter().for_each(|id| { + assert_valid_expression(format!("foo::{id}::\"bar\"")); + assert_valid_expression(format!("principal is {id}::foo")); + }); + + // RESERVED_IDENTS cannot be used in namespaces + RESERVED_IDENTS.into_iter().for_each(|id| { + assert_invalid_expression( + format!("foo::{id}::\"bar\""), + RESERVED_IDENT_MSG(id), + id.into(), + ); + assert_invalid_expression( + format!("principal is {id}::foo"), + RESERVED_IDENT_MSG(id), + id.into(), + ); + }); + + // RESERVED_NAMESPACE cannot be used in namespaces + RESERVED_NAMESPACE.into_iter().for_each(|id| { + assert_invalid_expression( + format!("foo::{id}::\"bar\""), + RESERVED_NAMESPACE_MSG(&format!("foo::{id}")), + format!("foo::{id}"), + ); + assert_invalid_expression( + format!("principal is {id}::foo"), + RESERVED_NAMESPACE_MSG(&format!("{id}::foo")), + format!("{id}::foo"), + ); + }); + } + + #[test] + fn test_reserved_extfun_names() { + // No keyword is allowed as an extension function names since we check + // against the known extension functions at parse time. + + RESERVED_IDENTS.into_iter().for_each(|id| { + assert_invalid_expression( + format!("extension::function::{id}(\"foo\")"), + RESERVED_IDENT_MSG(id), + id.into(), + ); + assert_invalid_expression( + format!("context.{id}(1)"), + RESERVED_IDENT_MSG(id), + id.into(), + ); + }); + + RESERVED_NAMESPACE.into_iter().for_each(|id| { + assert_invalid_expression( + format!("extension::function::{id}(\"foo\")"), + RESERVED_NAMESPACE_MSG(&format!("extension::function::{id}")), + format!("extension::function::{id}"), + ); + assert_invalid_expression( + format!("context.{id}(1)"), + RESERVED_NAMESPACE_MSG(id), + "context".into(), // TODO(#1221): wrong source is used + ); + }); + + OTHER_SPECIAL_IDENTS.into_iter().for_each(|id| { + assert_invalid_expression( + format!("extension::function::{id}(\"foo\")"), + format!("`extension::function::{id}` is not a valid function"), + format!("extension::function::{id}(\"foo\")"), + ); + assert_invalid_expression( + format!("context.{id}(1)"), + format!("`{id}` is not a valid method"), + format!("context.{id}(1)"), + ); + }); + } +} diff --git a/cedar-testing/Cargo.toml b/cedar-testing/Cargo.toml index 9e2dbfe854..d90dafd582 100644 --- a/cedar-testing/Cargo.toml +++ b/cedar-testing/Cargo.toml @@ -11,7 +11,7 @@ cedar-policy-core = { version = "=4.0.0", path = "../cedar-policy-core" } cedar-policy-validator = { version = "=4.0.0", path = "../cedar-policy-validator" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -smol_str = { version = "0.2", features = ["serde"] } +smol_str = { version = "0.3", features = ["serde"] } miette = { version = "7.1.0", features = ["fancy"] } [features] diff --git a/cedar-wasm/build-wasm.sh b/cedar-wasm/build-wasm.sh index b97254eb0a..88c572d725 100755 --- a/cedar-wasm/build-wasm.sh +++ b/cedar-wasm/build-wasm.sh @@ -87,6 +87,7 @@ process_types_file() { ' "$types_file" > "$types_file.tmp" && mv "$types_file.tmp" "$types_file" echo "type SmolStr = string;" >> "$types_file" + echo "export type TypeOfAttribute = Type & { required?: boolean };" >> "$types_file" } check_types_file() {