diff --git a/cedar-policy/src/api/id.rs b/cedar-policy/src/api/id.rs index 7a0cf3e45..f95d6e0a1 100644 --- a/cedar-policy/src/api/id.rs +++ b/cedar-policy/src/api/id.rs @@ -373,7 +373,7 @@ impl From for ast::PolicyID { /// Identifier for a Template slot #[repr(transparent)] #[allow(clippy::module_name_repetitions)] -#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Hash, RefCast)] +#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Hash, RefCast, Serialize, Deserialize)] pub struct SlotId(ast::SlotId); impl SlotId { diff --git a/cedar-policy/src/ffi/is_authorized.rs b/cedar-policy/src/ffi/is_authorized.rs index 3f1eee706..6f934c3e3 100644 --- a/cedar-policy/src/ffi/is_authorized.rs +++ b/cedar-policy/src/ffi/is_authorized.rs @@ -20,17 +20,15 @@ #[cfg(feature = "partial-eval")] use super::utils::JsonValueWithNoDuplicateKeys; use super::utils::{Context, DetailedError, Entities, EntityUid, PolicySet, Schema, WithWarnings}; -use crate::{Authorizer, Decision, EntityId, EntityTypeName, PolicyId, Request, SlotId}; +use crate::{Authorizer, Decision, PolicyId, Request}; use cedar_policy_validator::human_schema::SchemaWarning; -use itertools::Itertools; -use miette::{miette, Diagnostic}; use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, MapPreventDuplicates}; -use std::collections::{HashMap, HashSet}; +use serde_with::serde_as; +#[cfg(feature = "partial-eval")] +use std::collections::HashMap; +use std::collections::HashSet; #[cfg(feature = "partial-eval")] use std::convert::Infallible; -use std::str::FromStr; -use thiserror::Error; #[cfg(feature = "wasm")] extern crate tsify; @@ -481,8 +479,10 @@ pub struct AuthorizationCall { /// If a schema is not provided, this option has no effect. #[serde(default = "constant_true")] validate_request: bool, - /// The slice containing entities and policies - slice: RecvdSlice, + /// The set of policies to use during authorization + policies: PolicySet, + /// The set of entities to use during authorization + entities: Entities, } /// Struct containing the input data for partial authorization @@ -513,8 +513,10 @@ pub struct PartialAuthorizationCall { /// If a schema is not provided, this option has no effect. #[serde(default = "constant_true")] validate_request: bool, - /// The slice containing entities and policies - slice: RecvdSlice, + /// The set of policies to use during authorization + policies: PolicySet, + /// The set of entities to use during authorization + entities: Entities, } fn constant_true() -> bool { @@ -577,23 +579,23 @@ impl AuthorizationCall { } else { None }; - let request = match Request::new(principal, action, resource, context, schema_opt) { - Ok(request) => request, - Err(e) => { - return build_error(vec![e.into()], warnings); - } - }; + let maybe_request = Request::new(principal, action, resource, context, schema_opt) + .map_err(|e| errs.push(e.into())); + let maybe_entities = self + .entities + .parse(schema.as_ref()) + .map_err(|e| errs.push(e)); + let maybe_policies = self.policies.parse().map_err(|es| errs.extend(es)); - let (policies, entities) = match self.slice.try_into(schema.as_ref()) { - Ok((policies, entities)) => (policies, entities), - Err(errs) => { + match (maybe_request, maybe_policies, maybe_entities) { + (Ok(request), Ok(policies), Ok(entities)) => WithWarnings { + t: Ok((request, policies, entities)), + warnings: warnings.into_iter().map(Into::into).collect(), + }, + _ => { + // At least one of the `errs.push(e)` statements above must have been reached return build_error(errs, warnings); } - }; - - WithWarnings { - t: Ok((request, policies, entities)), - warnings: warnings.into_iter().map(Into::into).collect(), } } } @@ -646,12 +648,11 @@ impl PartialAuthorizationCall { } }; - let (policies, entities) = match self.slice.try_into(schema.as_ref()) { - Ok((policies, entities)) => (policies, entities), - Err(errs) => { - return build_error(errs, warnings); - } - }; + let maybe_entities = self + .entities + .parse(schema.as_ref()) + .map_err(|e| errs.push(e)); + let maybe_policies = self.policies.parse().map_err(|es| errs.extend(es)); let mut b = Request::builder(); if let Some(p) = principal { @@ -665,215 +666,41 @@ impl PartialAuthorizationCall { } b = b.context(context); - let request = match schema { - Some(schema) if self.validate_request => match b.schema(&schema).build() { - Ok(request) => request, - Err(e) => { - return build_error(vec![e.into()], warnings); - } - }, - _ => b.build(), - }; - - WithWarnings { - t: Ok((request, policies, entities)), - warnings: warnings.into_iter().map(Into::into).collect(), - } - } -} - -/// -/// Entity UID as strings. -/// -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -#[serde(rename_all = "camelCase")] -struct EntityUIDStrings { - ty: String, - eid: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -#[serde(rename_all = "camelCase")] -struct Link { - slot: String, - value: EntityUIDStrings, -} - -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -#[serde(rename_all = "camelCase")] -struct TemplateLink { - /// Template ID to fill in - template_id: String, - - /// Policy ID for resulting linked policy - result_policy_id: String, - - /// Links for all slots in policy template `template_id` - instantiations: Links, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(try_from = "Vec")] -#[serde(into = "Vec")] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -#[serde(rename_all = "camelCase")] -struct Links(Vec); - -/// Error returned for duplicate slot ids -#[derive(Debug, Clone, Diagnostic, Error)] -pub enum DuplicateLinkError { - /// Duplicate values for the same slot - #[error("duplicate values for the slot(s): {}", .0.iter().map(|s| format!("`{s}`")).join(", "))] - Duplicates(Vec), -} - -impl TryFrom> for Links { - type Error = DuplicateLinkError; - - fn try_from(links: Vec) -> Result { - let mut slots = links.iter().map(|link| &link.slot).collect::>(); - slots.sort(); - let duplicates = slots - .into_iter() - .dedup_with_count() - .filter_map(|(count, slot)| if count == 1 { None } else { Some(slot) }) - .cloned() - .collect::>(); - if duplicates.is_empty() { - Ok(Self(links)) - } else { - Err(DuplicateLinkError::Duplicates(duplicates)) - } - } -} - -impl From for Vec { - fn from(value: Links) -> Self { - value.0 - } -} - -/// policies must either be a single policy per entry, or only one entry with more than one policy -#[serde_as] -#[derive(Debug, Serialize, Deserialize)] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -#[serde(rename_all = "camelCase")] -struct RecvdSlice { - policies: PolicySet, - /// JSON object containing the entities data, in "natural JSON" form -- same - /// format as expected by EntityJsonParser - entities: Entities, - - /// Optional template policies. - #[serde_as(as = "Option>")] - templates: Option>, - - /// Optional template links - template_instantiations: Option>, -} - -fn parse_link(v: &Link) -> Result<(SlotId, crate::EntityUid), miette::Report> { - let slot = match v.slot.as_str() { - "?principal" => SlotId::principal(), - "?resource" => SlotId::resource(), - _ => { - return Err(miette!("Slot must be ?principal or ?resource")); - } - }; - let type_name = EntityTypeName::from_str(v.value.ty.as_str()).map_err(miette::Report::new)?; - let eid = match EntityId::from_str(v.value.eid.as_str()) { - Ok(eid) => eid, - Err(err) => match err {}, - }; - let entity_uid = crate::EntityUid::from_type_name_and_id(type_name, eid); - Ok((slot, entity_uid)) -} - -fn parse_links(policies: &mut crate::PolicySet, link: TemplateLink) -> Result<(), miette::Report> { - let template_id = PolicyId::from_str(link.template_id.as_str()); - let link_id = PolicyId::from_str(link.result_policy_id.as_str()); - match (template_id, link_id) { - (Err(never), _) | (_, Err(never)) => match never {}, - (Ok(template_id), Ok(link_id)) => { - let mut vals = HashMap::new(); - for i in link.instantiations.0 { - let (slot, euid) = parse_link(&i)?; - vals.insert(slot, euid); - } - policies - .link(template_id, link_id, vals) - .map_err(miette::Report::new) - } - } -} - -impl RecvdSlice { - #[allow(clippy::too_many_lines)] - fn try_into( - self, - schema: Option<&crate::Schema>, - ) -> Result<(crate::PolicySet, crate::Entities), Vec> { - let Self { - policies, - entities, - templates, - template_instantiations, - } = self; - - let mut errs = Vec::new(); - - let mut policies: crate::PolicySet = match policies.parse(templates) { - Ok(policies) => policies, - Err(e) => { - errs.extend(e); - crate::PolicySet::new() - } - }; - let entities = match entities.parse(schema) { - Ok(entities) => entities, - Err(e) => { - errs.push(e); - crate::Entities::empty() + let maybe_request = match schema { + Some(schema) if self.validate_request => { + b.schema(&schema).build().map_err(|e| errs.push(e.into())) } + _ => Ok(b.build()), }; - if let Some(links) = template_instantiations { - for link in links { - match parse_links(&mut policies, link) { - Ok(()) => (), - Err(e) => errs.push(e), - } + match (maybe_request, maybe_policies, maybe_entities) { + (Ok(request), Ok(policies), Ok(entities)) => WithWarnings { + t: Ok((request, policies, entities)), + warnings: warnings.into_iter().map(Into::into).collect(), + }, + _ => { + // At least one of the `errs.push(e)` statements above must have been reached + return build_error(errs, warnings); } } - - if errs.is_empty() { - Ok((policies, entities)) - } else { - Err(errs) - } } } // PANIC SAFETY unit tests #[allow(clippy::panic)] #[cfg(test)] -mod test { +pub mod test { use super::*; + + use crate::ffi::test_utils::*; use cool_asserts::assert_matches; use serde_json::json; - /// Assert that `is_authorized_json()` returns Allow with no errors + /// Assert that [`is_authorized_json()`] returns `Allow` with no errors #[track_caller] fn assert_is_authorized_json(json: serde_json::Value) { - let ans_val = is_authorized_json(json).unwrap(); + let ans_val = + is_authorized_json(json).expect("expected input to parse as an `AuthorizationCall`"); let result: Result = serde_json::from_value(ans_val); assert_matches!(result, Ok(AuthorizationAnswer::Success { response, .. }) => { assert_eq!(response.decision(), Decision::Allow); @@ -882,10 +709,11 @@ mod test { }); } - /// Assert that `is_authorized_json()` returns Deny with no errors + /// Assert that [`is_authorized_json()`] returns `Deny` with no errors #[track_caller] fn assert_is_not_authorized_json(json: serde_json::Value) { - let ans_val = is_authorized_json(json).unwrap(); + let ans_val = + is_authorized_json(json).expect("expected input to parse as an `AuthorizationCall`"); let result: Result = serde_json::from_value(ans_val); assert_matches!(result, Ok(AuthorizationAnswer::Success { response, .. }) => { assert_eq!(response.decision(), Decision::Deny); @@ -894,81 +722,31 @@ mod test { }); } - /// Assert that `is_authorized_json()` returns - /// `AuthorizationAnswer::Failure` where some error contains the expected - /// string `err` (in its main error message) + /// Assert that [`is_authorized_json_str()`] returns a `serde_json::Error` + /// error with a message that matches `msg` #[track_caller] - fn assert_is_authorized_json_is_failure(json: serde_json::Value, err: &str) { - let ans_val = - is_authorized_json(json).expect("expected it to at least parse into AuthorizationCall"); - let result: Result = serde_json::from_value(ans_val); - assert_matches!(result, Ok(AuthorizationAnswer::Failure { errors, .. }) => { - assert!( - errors.iter().any(|e| e.message.contains(err)), - "Expected to see error(s) containing `{err}`, but saw {errors:?}", - ); + fn assert_is_authorized_json_str_is_failure(call: &str, msg: &str) { + assert_matches!(is_authorized_json_str(call), Err(e) => { + assert_eq!(e.to_string(), msg); }); } - #[test] - fn test_slice_convert() { - let entities = serde_json::json!( - [ - { - "uid" : { - "type" : "user", - "id" : "alice" - }, - "attrs": { "foo": "bar" }, - "parents" : [ - { - "type" : "user", - "id" : "bob" - } - ] - }, - { - "uid" : { - "type" : "user", - "id" : "bob" - }, - "attrs": {}, - "parents": [] - } - ] - ); - let rslice = RecvdSlice { - policies: PolicySet::Map(HashMap::new()), - entities: entities.into(), - templates: None, - template_instantiations: None, - }; - let (policies, entities) = rslice.try_into(None).expect("parse failed"); - assert!(policies.is_empty()); - entities - .get(&crate::EntityUid::from_type_name_and_id( - "user".parse().unwrap(), - "alice".parse().unwrap(), - )) - .map_or_else( - || panic!("Missing user::alice Entity"), - |alice| { - assert!(entities.is_ancestor_of( - &crate::EntityUid::from_type_name_and_id( - "user".parse().unwrap(), - "bob".parse().unwrap() - ), - &alice.uid() - )); - }, - ); + /// Assert that [`is_authorized_json()`] returns [`AuthorizationAnswer::Failure`] + /// and return the enclosed errors + #[track_caller] + fn assert_is_authorized_json_is_failure(json: serde_json::Value) -> Vec { + let ans_val = + is_authorized_json(json).expect("expected input to parse as an `AuthorizationCall`"); + let result: Result = serde_json::from_value(ans_val); + assert_matches!(result, Ok(AuthorizationAnswer::Failure { errors, .. }) => errors) } #[test] fn test_failure_on_invalid_syntax() { - assert_matches!(is_authorized_json_str("iefjieoafiaeosij"), Err(e) => { - assert!(e.to_string().contains("expected value")); - }); + assert_is_authorized_json_str_is_failure( + "iefjieoafiaeosij", + "expected value at line 1 column 1", + ); } #[test] @@ -987,12 +765,9 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": {}, - "entities": [] - } + "policies": {}, + "entities": [] }); - assert_is_not_authorized_json(call); } @@ -1009,16 +784,20 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": { - "ID1": "permit(principal == User::\"alice\", action, resource);" - }, - "entities": [] - } + "policies": { + "staticPolicies": { + "ID1": "permit(principal == User::\"alice\", action, resource);" + } + }, + "entities": [] }); - - let msg = "failed to parse principal"; - assert_is_authorized_json_is_failure(call, msg); + // unspecified entities are no longer supported + let errs = assert_is_authorized_json_is_failure(call); + assert_exactly_one_error( + &errs, + "failed to parse principal: in uid field of , expected a literal entity reference, but got `null`", + Some("literal entity references can be made with `{ \"type\": \"SomeType\", \"id\": \"SomeId\" }`"), + ); } #[test] @@ -1037,14 +816,13 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": { - "ID1": "permit(principal == User::\"alice\", action, resource);" - }, - "entities": [] - } + "policies": { + "staticPolicies": { + "ID1": "permit(principal == User::\"alice\", action, resource);" + } + }, + "entities": [] }); - assert_is_authorized_json(call); } @@ -1064,12 +842,11 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": "permit(principal == User::\"alice\", action, resource);", - "entities": [] - } + "policies": { + "staticPolicies": "permit(principal == User::\"alice\", action, resource);" + }, + "entities": [] }); - assert_is_authorized_json(call); } @@ -1094,12 +871,11 @@ mod test { "__extn" : { "fn" : "ip", "arg" : "222.222.222.222" } } }, - "slice": { - "policies": "permit(principal == User::\"alice\", action, resource) when { context.is_authenticated && context.source_ip.isInRange(ip(\"222.222.222.0/24\")) };", - "entities": [] - } + "policies": { + "staticPolicies": "permit(principal == User::\"alice\", action, resource) when { context.is_authenticated && context.source_ip.isInRange(ip(\"222.222.222.0/24\")) };" + }, + "entities": [] }); - assert_is_authorized_json(call); } @@ -1119,8 +895,9 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": "permit(principal, action, resource in Folder::\"house\") when { resource.owner == principal };", + "policies": { + "staticPolicies": "permit(principal, action, resource in Folder::\"house\") when { resource.owner == principal };" + }, "entities": [ { "uid": { @@ -1167,7 +944,6 @@ mod test { "parents": [] } ] - } }); assert_is_authorized_json(call); } @@ -1188,14 +964,14 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": { - "ID0": "permit(principal == User::\"jerry\", action, resource == Photo::\"doorx\");", - "ID1": "permit(principal == User::\"tom\", action, resource == Photo::\"doory\");", - "ID2": "permit(principal == User::\"alice\", action, resource == Photo::\"door\");" - }, - "entities": [] - } + "policies": { + "staticPolicies": { + "ID0": "permit(principal == User::\"jerry\", action, resource == Photo::\"doorx\");", + "ID1": "permit(principal == User::\"tom\", action, resource == Photo::\"doory\");", + "ID2": "permit(principal == User::\"alice\", action, resource == Photo::\"door\");" + } + }, + "entities": [] }); assert_is_authorized_json(call); } @@ -1216,8 +992,9 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": "permit(principal, action, resource in Folder::\"house\") when { resource.owner == principal };", + "policies": { + "staticPolicies": "permit(principal, action, resource in Folder::\"house\") when { resource.owner == principal };" + }, "entities": [ { "uid": { @@ -1264,7 +1041,6 @@ mod test { "parents": [] } ] - } }); assert_is_authorized_json(call); } @@ -1285,13 +1061,13 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": { - "ID0": "permit(principal, action, resource);", - "ID1": "forbid(principal == User::\"alice\", action, resource == Photo::\"door\");" - }, + "policies": { + "staticPolicies": { + "ID0": "permit(principal, action, resource);", + "ID1": "forbid(principal == User::\"alice\", action, resource == Photo::\"door\");" + } + }, "entities": [] - } }); assert_is_not_authorized_json(call); } @@ -1312,12 +1088,11 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": "permit(principal, action, resource);forbid(principal == User::\"alice\", action, resource);", + "policies": { + "staticPolicies": "permit(principal, action, resource);\nforbid(principal == User::\"alice\", action, resource);" + }, "entities": [] - } }); - assert_is_not_authorized_json(call); } @@ -1337,11 +1112,10 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": "permit(principal == ?principal, action, resource);", - "entities": [], - "templates": {} - } + "policies": { + "staticPolicies": "permit(principal == ?principal, action, resource);" + }, + "entities": [] }); assert_is_not_authorized_json(call); } @@ -1362,13 +1136,12 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": {}, - "entities": [], - "templates": { - "ID0": "permit(principal == ?principal, action, resource);" - } - } + "policies": { + "templates": { + "ID0": "permit(principal == ?principal, action, resource);" + } + }, + "entities": [], }); assert_is_not_authorized_json(call); } @@ -1389,28 +1162,21 @@ mod test { "id": "door" }, "context": {}, - "slice": { - "policies": {}, - "entities": [], - "templates": { - "ID0": "permit(principal == ?principal, action, resource);" - }, - "templateInstantiations": [ - { - "templateId": "ID0", - "resultPolicyId": "ID0_User_alice", - "instantiations": [ - { - "slot": "?principal", - "value": { - "ty": "User", - "eid": "alice" - } - } - ] - } - ] - } + "policies": { + "templates": { + "ID0": "permit(principal == ?principal, action, resource);" + }, + "templateLinks": [ + { + "templateId": "ID0", + "newId": "ID0_User_alice", + "values": { + "?principal": { "type": "User", "id": "alice" } + } + } + ] + }, + "entities": [] }); assert_is_authorized_json(call); } @@ -1431,16 +1197,21 @@ mod test { "id" : "door" }, "context" : {}, - "slice" : { - "policies" : { "ID0": "permit(principal, action, resource);" }, - "entities" : [], - "templates" : { "ID0": "permit(principal == ?principal, action, resource);" }, - "templateInstantiations" : [] - } + "policies": { + "staticPolicies": { + "ID0": "permit(principal, action, resource);" + }, + "templates": { + "ID0": "permit(principal == ?principal, action, resource);" + } + }, + "entities" : [] }); - assert_is_authorized_json_is_failure( - call, + let errs = assert_is_authorized_json_is_failure(call); + assert_exactly_one_error( + &errs, "failed to add template with id `ID0` to policy set: duplicate template or policy id `ID0`", + None, ); } @@ -1460,37 +1231,30 @@ mod test { "id" : "door" }, "context" : {}, - "slice" : { - "policies" : {}, - "entities" : [], - "templates" : { "ID0": "permit(principal == ?principal, action, resource);" }, - "templateInstantiations" : [ + "policies" : { + "templates": { + "ID0": "permit(principal == ?principal, action, resource);" + }, + "templateLinks" : [ { "templateId" : "ID0", - "resultPolicyId" : "ID1", - "instantiations" : [ - { - "slot": "?principal", - "value": { "ty" : "User", "eid" : "alice" } - } - ] + "newId" : "ID1", + "values" : { "?principal": { "type" : "User", "id" : "alice" } } }, { "templateId" : "ID0", - "resultPolicyId" : "ID1", - "instantiations" : [ - { - "slot": "?principal", - "value": { "ty" : "User", "eid" : "alice" } - } - ] + "newId" : "ID1", + "values" : { "?principal": { "type" : "User", "id" : "alice" } } } ] - } + }, + "entities" : [], }); - assert_is_authorized_json_is_failure( - call, + let errs = assert_is_authorized_json_is_failure(call); + assert_exactly_one_error( + &errs, "unable to link template: template-linked policy id `ID1` conflicts with an existing policy id", + None, ); } @@ -1510,27 +1274,26 @@ mod test { "id" : "door" }, "context" : {}, - "slice" : { - "policies" : {}, - "entities" : [], - "templates" : { "ID0": "permit(principal == ?principal, action, resource);" }, - "templateInstantiations" : [ + "policies" : { + "templates": { + "ID0": "permit(principal == ?principal, action, resource);" + }, + "templateLinks" : [ { "templateId" : "ID0", - "resultPolicyId" : "ID0", - "instantiations" : [ - { - "slot": "?principal", - "value": { "ty" : "User", "eid" : "alice" } - } - ] + "newId" : "ID0", + "values" : { "?principal": { "type" : "User", "id" : "alice" } } } ] - } + }, + "entities" : [] + }); - assert_is_authorized_json_is_failure( - call, + let errs = assert_is_authorized_json_is_failure(call); + assert_exactly_one_error( + &errs, "unable to link template: template-linked policy id `ID0` conflicts with an existing policy id", + None, ); } @@ -1550,186 +1313,126 @@ mod test { "id" : "door" }, "context" : {}, - "slice" : { - "policies" : { "ID1": "permit(principal, action, resource);" }, - "entities" : [], - "templates" : { "ID0": "permit(principal == ?principal, action, resource);" }, - "templateInstantiations" : [ + "policies" : { + "staticPolicies" : { + "ID1": "permit(principal, action, resource);" + }, + "templates": { + "ID0": "permit(principal == ?principal, action, resource);" + }, + "templateLinks" : [ { "templateId" : "ID0", - "resultPolicyId" : "ID1", - "instantiations" : [ - { - "slot": "?principal", - "value": { "ty" : "User", "eid" : "alice" } - } - ] + "newId" : "ID1", + "values" : { "?principal": { "type" : "User", "id" : "alice" } } } ] - } + }, + "entities" : [] }); - assert_is_authorized_json_is_failure( - call, + let errs = assert_is_authorized_json_is_failure(call); + assert_exactly_one_error( + &errs, "unable to link template: template-linked policy id `ID1` conflicts with an existing policy id", + None, ); } #[test] fn test_authorized_fails_on_duplicate_policy_ids() { let call = r#"{ - "principal" : "User::\"alice\"", - "action" : "Photo::\"view\"", - "resource" : "Photo::\"door\"", + "principal" : { + "type" : "User", + "id" : "alice" + }, + "action" : { + "type" : "Action", + "id" : "view" + }, + "resource" : { + "type" : "Photo", + "id" : "door" + }, "context" : {}, - "slice" : { - "policies" : { - "ID0": "permit(principal, action, resource);", - "ID0": "permit(principal, action, resource);" - }, - "entities" : [], - "templates" : {}, - "templateInstantiations" : [ ] - } + "policies" : { + "staticPolicies" : { + "ID0": "permit(principal, action, resource);", + "ID0": "permit(principal, action, resource);" + } + }, + "entities" : [], }"#; - assert_matches!(is_authorized_json_str(call), Err(e) => { - assert!(e.to_string().contains("policies as a concatenated string or multiple policies as a hashmap where the policy id is the key")); - }); + assert_is_authorized_json_str_is_failure( + call, + "expected a static policy set represented by a string, JSON array, or JSON object (with no duplicate keys) at line 20 column 13", + ); } #[test] fn test_authorized_fails_on_duplicate_template_ids() { let call = r#"{ - "principal" : "User::\"alice\"", - "action" : "Photo::\"view\"", - "resource" : "Photo::\"door\"", + "principal" : { + "type" : "User", + "id" : "alice" + }, + "action" : { + "type" : "Action", + "id" : "view" + }, + "resource" : { + "type" : "Photo", + "id" : "door" + }, "context" : {}, - "slice" : { - "policies" : {}, - "entities" : [], + "policies" : { "templates" : { "ID0": "permit(principal == ?principal, action, resource);", "ID0": "permit(principal == ?principal, action, resource);" - }, - "templateInstantiations" : [ ] - } + } + }, + "entities" : [] }"#; - assert_matches!(is_authorized_json_str(call), Err(e) => { - assert!(e.to_string().contains("found duplicate key")); - }); - } - - #[test] - fn test_authorized_fails_on_duplicate_slot_link1() { - let call = json!({ - "principal" : "User::\"alice\"", - "action" : "Photo::\"view\"", - "resource" : "Photo::\"door\"", - "context" : {}, - "slice" : { - "policies" : {}, - "entities" : [], - "templates" : { "ID0": "permit(principal == ?principal, action, resource);" }, - "templateInstantiations" : [ - { - "templateId" : "ID0", - "resultPolicyId" : "ID1", - "instantiations" : [ - { - "slot": "?principal", - "value": { "ty" : "User", "eid" : "alice" } - }, - { - "slot": "?principal", - "value": { "ty" : "User", "eid" : "alice" } - } - ] - } - ] - } - }); - assert_matches!(is_authorized_json(call), Err(e) => { - assert!(e.to_string().contains("duplicate values for the slot(s): `?principal`")); - }); - } - - #[test] - fn test_authorized_fails_on_duplicate_slot_link2() { - let call = json!({ - "principal" : "User::\"alice\"", - "action" : "Photo::\"view\"", - "resource" : "Photo::\"door\"", - "context" : {}, - "slice" : { - "policies" : {}, - "entities" : [], - "templates" : { "ID0": "permit(principal == ?principal, action, resource);" }, - "templateInstantiations" : [ - { - "templateId" : "ID0", - "resultPolicyId" : "ID1", - "instantiations" : [ - { - "slot": "?principal", - "value": { "ty" : "User", "eid" : "alice" } - }, - { - "slot" : "?resource", - "value" : { "ty" : "Box", "eid" : "box" } - }, - { - "slot": "?principal", - "value": { "ty" : "User", "eid" : "alice" } - } - ] - } - ] - } - }); - assert_matches!(is_authorized_json(call), Err(e) => { - assert!(e.to_string().contains("duplicate values for the slot(s): `?principal`")); - }); + assert_is_authorized_json_str_is_failure( + call, + "invalid entry: found duplicate key at line 19 column 17", + ); } #[test] - fn test_authorized_fails_on_duplicate_slot_link3() { - let call = json!({ - "principal" : "User::\"alice\"", - "action" : "Photo::\"view\"", - "resource" : "Photo::\"door\"", + fn test_authorized_fails_on_duplicate_slot_link() { + let call = r#"{ + "principal" : { + "type" : "User", + "id" : "alice" + }, + "action" : { + "type" : "Action", + "id" : "view" + }, + "resource" : { + "type" : "Photo", + "id" : "door" + }, "context" : {}, - "slice" : { - "policies" : {}, - "entities" : [], - "templates" : { "ID0": "permit(principal == ?principal, action, resource);" }, - "templateInstantiations" : [ - { - "templateId" : "ID0", - "resultPolicyId" : "ID1", - "instantiations" : [ - { - "slot": "?principal", - "value": { "ty" : "User", "eid" : "alice" } - }, - { - "slot" : "?resource", - "value" : { "ty" : "Box", "eid" : "box" } - }, - { - "slot": "?principal", - "value": { "ty" : "Team", "eid" : "bob" } - }, - { - "slot" : "?resource", - "value" : { "ty" : "Box", "eid" : "box2" } - } - ] + "policies" : { + "templates" : { + "ID0": "permit(principal == ?principal, action, resource);" + }, + "templateLinks" : [{ + "templateId" : "ID0", + "newId" : "ID1", + "values" : { + "?principal": { "type" : "User", "id" : "alice" }, + "?principal": { "type" : "User", "id" : "alice" } } - ] - } - }); - assert_matches!(is_authorized_json(call), Err(e) => { - assert!(e.to_string().contains("duplicate values for the slot(s): `?principal`, `?resource`")); - }); + }] + }, + "entities" : [], + }"#; + assert_is_authorized_json_str_is_failure( + call, + "invalid entry: found duplicate key at line 25 column 21", + ); } #[test] @@ -1748,31 +1451,28 @@ mod test { "id" : "door" }, "context" : {}, - "slice" : { - "policies" : {}, - "entities" : [ - { - "uid": { - "type" : "User", - "id" : "alice" - }, - "attrs": {}, - "parents": [] + "policies" : {}, + "entities" : [ + { + "uid": { + "type" : "User", + "id" : "alice" }, - { - "uid": { - "type" : "User", - "id" : "alice" - }, - "attrs": {}, - "parents": [] - } - ], - "templates" : {}, - "templateInstantiations" : [] - } + "attrs": {}, + "parents": [] + }, + { + "uid": { + "type" : "User", + "id" : "alice" + }, + "attrs": {}, + "parents": [] + } + ] }); - assert_is_authorized_json_is_failure(call, r#"duplicate entity entry `User::"alice"`"#); + let errs = assert_is_authorized_json_is_failure(call); + assert_exactly_one_error(&errs, r#"duplicate entity entry `User::"alice"`"#, None); } #[test] @@ -1794,16 +1494,13 @@ mod test { "is_authenticated": true, "is_authenticated": false }, - "slice" : { - "policies" : {}, - "entities" : [], - "templates" : {}, - "templateInstantiations" : [] - } + "policies" : {}, + "entities" : [], }"#; - assert_matches!(is_authorized_json_str(call), Err(e) => { - assert!(e.to_string() == "the key `is_authenticated` occurs two or more times in the same JSON object at line 17 column 13"); - }); + assert_is_authorized_json_str_is_failure( + call, + "the key `is_authenticated` occurs two or more times in the same JSON object at line 17 column 13", + ); } #[test] @@ -1822,15 +1519,11 @@ mod test { "id": "door", }, "context": {}, - "slice": { - "policies": "permit(principal == User::\"alice\", action == Action::\"view\", resource);", - "entities": [], - "templates": {}, - "templateInstantiations": [], - }, - "schema": { - "human": "entity User, Photo; action view appliesTo { principal: User, resource: Photo };" + "policies": { + "staticPolicies": "permit(principal == User::\"alice\", action == Action::\"view\", resource);" }, + "entities": [], + "schema": "entity User, Photo; action view appliesTo { principal: User, resource: Photo };" }); let bad_call = json!({ "principal" : { @@ -1846,15 +1539,11 @@ mod test { "id": "bob", }, "context": {}, - "slice": { - "policies": "permit(principal == User::\"alice\", action == Action::\"view\", resource);", - "entities": [], - "templates": {}, - "templateInstantiations": [], - }, - "schema": { - "human": "entity User, Photo; action view appliesTo { principal: User, resource: Photo };" + "policies": { + "staticPolicies": "permit(principal == User::\"alice\", action == Action::\"view\", resource);" }, + "entities": [], + "schema": "entity User, Photo; action view appliesTo { principal: User, resource: Photo };" }); let bad_call_req_validation_disabled = json!({ "principal" : { @@ -1870,22 +1559,20 @@ mod test { "id": "bob", }, "context": {}, - "slice": { - "policies": "permit(principal == User::\"alice\", action == Action::\"view\", resource);", - "entities": [], - "templates": {}, - "templateInstantiations": [], - }, - "schema": { - "human": "entity User, Photo; action view appliesTo { principal: User, resource: Photo };" + "policies": { + "staticPolicies": "permit(principal == User::\"alice\", action == Action::\"view\", resource);" }, + "entities": [], + "schema": "entity User, Photo; action view appliesTo { principal: User, resource: Photo };", "validateRequest": false, }); assert_is_authorized_json(good_call); - assert_is_authorized_json_is_failure( - bad_call, + let errs = assert_is_authorized_json_is_failure(bad_call); + assert_exactly_one_error( + &errs, "resource type `User` is not valid for `Action::\"view\"`", + None, ); assert_is_authorized_json(bad_call_req_validation_disabled); } @@ -1921,7 +1608,7 @@ mod partial_test { } #[track_caller] - fn assert_is_residual(call: serde_json::Value, expected_residuals: HashSet<&str>) { + fn assert_is_residual(call: serde_json::Value, expected_residuals: &HashSet<&str>) { let ans_val = is_authorized_partial_json(call).unwrap(); let result: Result = serde_json::from_value(ans_val); assert_matches!(result, Ok(PartialAuthorizationAnswer::Residuals { response, .. }) => { @@ -1929,11 +1616,11 @@ mod partial_test { let errors: Vec<_> = response.errored().collect(); assert_eq!(errors.len(), 0, "{errors:?}"); let actual_residuals: HashSet<_> = response.nontrivial_residual_ids().collect(); - for id in &expected_residuals { - assert!(actual_residuals.contains(&PolicyId::from_str(id).ok().unwrap()), "expected nontrivial residual for {id}, but it's missing") + for id in expected_residuals { + assert!(actual_residuals.contains(&PolicyId::new(id)), "expected nontrivial residual for {id}, but it's missing"); } for id in &actual_residuals { - assert!(expected_residuals.contains(id.to_string().as_str()),"found unexpected nontrivial residual for {id}") + assert!(expected_residuals.contains(id.to_string().as_str()),"found unexpected nontrivial residual for {id}"); } }); } @@ -1950,12 +1637,12 @@ mod partial_test { "id": "view" }, "context": {}, - "slice": { - "policies": { - "ID1": "permit(principal == User::\"alice\", action, resource);" - }, - "entities": [] + "policies": { + "staticPolicies": { + "ID1": "permit(principal == User::\"alice\", action, resource);" + } }, + "entities": [], "partial_evaluation": true }); @@ -1974,12 +1661,12 @@ mod partial_test { "id": "view" }, "context": {}, - "slice": { - "policies": { - "ID1": "permit(principal == User::\"alice\", action, resource);" - }, - "entities": [] + "policies": { + "staticPolicies": { + "ID1": "permit(principal == User::\"alice\", action, resource);" + } }, + "entities": [], "partial_evaluation": true }); @@ -1998,16 +1685,16 @@ mod partial_test { "id" : "door" }, "context": {}, - "slice": { - "policies": { - "ID1": "permit(principal == User::\"alice\", action, resource);" - }, - "entities": [] + "policies": { + "staticPolicies": { + "ID1": "permit(principal == User::\"alice\", action, resource);" + } }, + "entities": [], "partial_evaluation": true }); - assert_is_residual(call, HashSet::from(["ID1"])); + assert_is_residual(call, &HashSet::from(["ID1"])); } #[test] @@ -2022,16 +1709,16 @@ mod partial_test { "id" : "door" }, "context": {}, - "slice": { - "policies": { - "ID1": "permit(principal, action, resource) when { principal == User::\"alice\" };" - }, - "entities": [] + "policies" : { + "staticPolicies" : { + "ID1": "permit(principal, action, resource) when { principal == User::\"alice\" };" + } }, + "entities": [], "partial_evaluation": true }); - assert_is_residual(call, HashSet::from(["ID1"])); + assert_is_residual(call, &HashSet::from(["ID1"])); } #[test] @@ -2046,16 +1733,16 @@ mod partial_test { "id" : "door" }, "context": {}, - "slice": { - "policies": { - "ID1": "permit(principal, action, resource) when { principal == User::\"alice\" };", - "ID2": "forbid(principal, action, resource) unless { resource == Photo::\"door\" };" - }, - "entities": [] + "policies" : { + "staticPolicies" : { + "ID1": "permit(principal, action, resource) when { principal == User::\"alice\" };", + "ID2": "forbid(principal, action, resource) unless { resource == Photo::\"door\" };" + } }, + "entities": [], "partial_evaluation": true }); - assert_is_residual(call, HashSet::from(["ID1"])); + assert_is_residual(call, &HashSet::from(["ID1"])); } } diff --git a/cedar-policy/src/ffi/utils.rs b/cedar-policy/src/ffi/utils.rs index 104384cd7..57cd014de 100644 --- a/cedar-policy/src/ffi/utils.rs +++ b/cedar-policy/src/ffi/utils.rs @@ -15,7 +15,7 @@ */ //! Utility functions and types for JSON interface -use crate::{Policy, SchemaWarning, Template}; +use crate::{PolicyId, SchemaWarning, SlotId}; use miette::WrapErr; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, str::FromStr}; @@ -259,84 +259,235 @@ impl From for Entities { } } -#[derive(Debug, Serialize, Deserialize)] +/// Represents a static policy in either the Cedar or JSON policy format +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] #[serde(untagged)] #[serde( - expecting = "policies as a concatenated string or multiple policies as a hashmap where the policy id is the key" + expecting = "expected a static policy in the Cedar or JSON policy format (with no duplicate keys)" )] +pub enum Policy { + /// Policy in the Cedar policy format. See + Human(String), + /// Policy in Cedar's JSON policy format. See + Json(JsonValueWithNoDuplicateKeys), +} + +impl Policy { + /// Parse a [`Policy`] into a [`crate::Policy`]. Takes an optional id + /// argument that sets the policy id. If the argument is `None` then a + /// default id will be assigned. + pub(super) fn parse(self, id: Option) -> Result { + let msg = id + .clone() + .map_or(String::new(), |id| format!(" with id `{id}`")); + match self { + Self::Human(str) => crate::Policy::parse(id.map(|id| id.to_string()), str) + .wrap_err(format!("failed to parse policy{msg} from string")), + Self::Json(json) => crate::Policy::from_json(id, json.into()) + .wrap_err(format!("failed to parse policy{msg} from JSON")), + } + } +} + +/// Represents a policy template in either the Cedar or JSON policy format +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -#[serde(rename_all = "camelCase")] -/// Struct defining the two possible ways to pass a set of policies to `is_authorized_json` and `validate_json` -pub enum PolicySet { - /// provides multiple policies as a concatenated string +#[serde(untagged)] +#[serde( + expecting = "expected a policy template in the Cedar or JSON policy format (with no duplicate keys)" +)] +pub enum Template { + /// Template in the Cedar policy format. See + Human(String), + /// Template in Cedar's JSON policy format. See + Json(JsonValueWithNoDuplicateKeys), +} + +impl Template { + /// Parse a [`Template`] into a [`crate::Template`]. Takes an optional id + /// argument that sets the template id. If the argument is `None` then a + /// default id will be assigned. + pub(super) fn parse(self, id: Option) -> Result { + let msg = id + .clone() + .map(|id| format!(" with id `{id}`")) + .unwrap_or_default(); + match self { + Self::Human(str) => crate::Template::parse(id.map(|id| id.to_string()), str) + .wrap_err(format!("failed to parse template{msg} from string")), + Self::Json(json) => crate::Template::from_json(id, json.into()) + .wrap_err(format!("failed to parse template{msg} from JSON")), + } + } + + /// Parse a [`Template`] into a [`crate::Template`] and add it into the + /// provided [`crate::PolicySet`]. + pub(super) fn parse_and_add_to_set( + self, + id: Option, + policies: &mut crate::PolicySet, + ) -> Result<(), miette::Report> { + let msg = id + .clone() + .map(|id| format!(" with id `{id}`")) + .unwrap_or_default(); + let template = self.parse(id)?; + policies + .add_template(template) + .wrap_err(format!("failed to add template{msg} to policy set")) + } +} + +/// Represents a set of static policies +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +#[serde(untagged)] +#[serde( + expecting = "expected a static policy set represented by a string, JSON array, or JSON object (with no duplicate keys)" +)] +pub enum StaticPolicySet { + /// Multiple policies as a concatenated string. Requires policies in the + /// Cedar (non-JSON) format. Concatenated(String), - /// provides multiple policies as a hashmap where the policyId is the key + /// Multiple policies as a set + Set(Vec), + /// Multiple policies as a hashmap where the policy id is the key #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")] - Map(HashMap), + Map(HashMap), } -fn parse_policy_set_from_individual_policies( - policies: &HashMap, - templates: Option>, -) -> Result> { - let mut policy_set = crate::PolicySet::new(); - let mut errs = Vec::new(); - for (id, policy_src) in policies { - match Policy::parse(Some(id.clone()), policy_src) - .wrap_err_with(|| format!("failed to parse policy with id `{id}`")) - { - Ok(p) => match policy_set - .add(p) - .wrap_err_with(|| format!("failed to add policy with id `{id}` to policy set")) - { - Ok(()) => {} - Err(e) => { - errs.push(e); +impl StaticPolicySet { + /// Parse a [`StaticPolicySet`] into a [`crate::PolicySet`] + pub(super) fn parse(self) -> Result> { + match self { + Self::Concatenated(str) => crate::PolicySet::from_str(&str) + .wrap_err("failed to parse policies from string") + .map_err(|e| vec![e]), + Self::Set(set) => { + let mut errs = Vec::new(); + let policies = set + .into_iter() + .map(|policy| policy.parse(None)) + .filter_map(|r| r.map_err(|e| errs.push(e)).ok()) + .collect::>(); + if errs.is_empty() { + crate::PolicySet::from_policies(policies).map_err(|e| vec![e.into()]) + } else { + Err(errs) + } + } + Self::Map(map) => { + let mut errs = Vec::new(); + let policies = map + .into_iter() + .map(|(id, policy)| policy.parse(Some(id))) + .filter_map(|r| r.map_err(|e| errs.push(e)).ok()) + .collect::>(); + if errs.is_empty() { + crate::PolicySet::from_policies(policies).map_err(|e| vec![e.into()]) + } else { + Err(errs) } - }, - Err(e) => { - errs.push(e); } } } +} - if let Some(templates) = templates { - for (id, policy_src) in templates { - match Template::parse(Some(id.clone()), policy_src) - .wrap_err_with(|| format!("failed to parse template with id `{id}`")) - { - Ok(p) => match policy_set.add_template(p).wrap_err_with(|| { - format!("failed to add template with id `{id}` to policy set") - }) { - Ok(()) => {} - Err(e) => { - errs.push(e); - } - }, - Err(e) => errs.push(e), - } - } +impl Default for StaticPolicySet { + fn default() -> Self { + Self::Set(Vec::new()) } +} + +/// Represents a template-linked policy +#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "camelCase")] +pub struct TemplateLink { + /// Id of the template to link against + template_id: PolicyId, + /// Id of the generated policy + new_id: PolicyId, + /// Values for the slots; keys must be slot ids (i.e., `?principal` or `?resource`) + #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")] + values: HashMap, +} - if errs.is_empty() { - Ok(policy_set) - } else { - Err(errs) +impl TemplateLink { + /// Parse a [`TemplateLink`] and add the linked policy into the provided [`crate::PolicySet`] + pub(super) fn parse_and_add_to_set( + self, + policies: &mut crate::PolicySet, + ) -> Result<(), miette::Report> { + let values: HashMap<_, _> = self + .values + .into_iter() + .map(|(slot, euid)| euid.parse(None).map(|euid| (slot, euid))) + .collect::, _>>() + .wrap_err("failed to parse link values")?; + policies + .link(self.template_id, self.new_id, values) + .map_err(miette::Report::new) } } +/// Represents a policy set, including static policies, templates, and template links +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] +#[serde(rename_all = "camelCase")] +pub struct PolicySet { + /// static policies + #[serde(default)] + static_policies: StaticPolicySet, + /// a map from template id to template content + #[serde(with = "::serde_with::rust::maps_duplicate_key_is_error")] + #[serde(default)] + templates: HashMap, + /// template links + #[serde(default)] + template_links: Vec, +} + impl PolicySet { - /// Parse the `PolicySet` into a `crate::PolicySet`. - pub(super) fn parse( - self, - templates: Option>, - ) -> Result> { - match self { - Self::Concatenated(policies) => crate::PolicySet::from_str(&policies) - .wrap_err("failed to parse policies from string") - .map_err(|e| vec![e]), - Self::Map(policies) => parse_policy_set_from_individual_policies(&policies, templates), + /// Parse a [`PolicySet`] into a [`crate::PolicySet`] + pub(super) fn parse(self) -> Result> { + let mut errs = Vec::new(); + // Parse static policies + let mut policies = self.static_policies.parse().unwrap_or_else(|mut e| { + errs.append(&mut e); + crate::PolicySet::new() + }); + // Parse templates & add them to the policy set + self.templates.into_iter().for_each(|(id, template)| { + template + .parse_and_add_to_set(Some(id), &mut policies) + .unwrap_or_else(|e| errs.push(e)); + }); + // Parse template links & add the resulting policies to the policy set + self.template_links.into_iter().for_each(|link| { + link.parse_and_add_to_set(&mut policies) + .unwrap_or_else(|e| errs.push(e)); + }); + // Return an error or the final policy set + if !errs.is_empty() { + return Err(errs); + } + Ok(policies) + } + + /// Create an empty [`PolicySet`] + #[cfg(test)] + pub(super) fn new() -> Self { + Self { + static_policies: StaticPolicySet::Set(Vec::new()), + templates: HashMap::new(), + template_links: Vec::new(), } } } @@ -345,7 +496,10 @@ impl PolicySet { #[derive(Debug, Serialize, Deserialize)] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -#[serde(rename_all = "camelCase")] +#[serde(untagged)] +#[serde( + expecting = "expected a schema in the Cedar or JSON policy format (with no duplicate keys)" +)] pub enum Schema { /// Schema in the Cedar schema format. See Human(String), @@ -365,7 +519,7 @@ impl Schema { Box::new(warnings) as Box>, ) }) - .map_err(miette::Report::new), + .wrap_err("failed to parse schema from string"), Self::Json(val) => crate::Schema::from_json_value(val.into()) .map(|sch| { ( @@ -373,7 +527,7 @@ impl Schema { Box::new(std::iter::empty()) as Box>, ) }) - .map_err(miette::Report::new), + .wrap_err("failed to parse schema from JSON"), } } } @@ -384,46 +538,509 @@ pub(super) struct WithWarnings { } // PANIC SAFETY unit tests -#[allow(clippy::panic)] +#[allow(clippy::panic, clippy::indexing_slicing)] +// Also disable some other clippy lints that are unimportant for testing code +#[allow(clippy::module_name_repetitions, clippy::missing_panics_doc)] +#[cfg(test)] +pub mod test_utils { + use super::*; + + /// Assert that an error has the specified message and help fields. + #[track_caller] + pub fn assert_error_matches(err: &DetailedError, msg: &str, help: Option<&str>) { + assert_eq!(err.message, msg, "did not see the expected error message"); + assert_eq!( + err.help, + help.map(Into::into), + "did not see the expected help message" + ); + } + + /// Assert that a vector (of errors) has the expected length + #[track_caller] + pub fn assert_length_matches(errs: &[T], n: usize) { + assert_eq!( + errs.len(), + n, + "expected {n} error(s) but saw {}", + errs.len() + ); + } + + /// Assert that a vector contains exactly one error with the specified + /// message and help text. + #[track_caller] + pub fn assert_exactly_one_error(errs: &[DetailedError], msg: &str, help: Option<&str>) { + assert_length_matches(errs, 1); + assert_error_matches(&errs[0], msg, help); + } +} + +// PANIC SAFETY unit tests +#[allow(clippy::panic, clippy::indexing_slicing)] +// Also disable some other clippy lints that are unimportant for testing code +#[allow(clippy::too_many_lines)] #[cfg(test)] mod test { use super::*; use cedar_policy_core::test_utils::*; use serde_json::json; + use test_utils::assert_length_matches; #[test] - fn test_schema_parser() { - // Cedar syntax - let schema_json = json!({ - "human": "entity User = { name: String};\nentity Photo;\naction viewPhoto appliesTo { principal: User, resource: Photo };" + fn test_policy_parser() { + // A string literal will be parsed as a policy in the Cedar syntax + let policy_json = json!("permit(principal == User::\"alice\", action, resource);"); + let policy: Policy = + serde_json::from_value(policy_json).expect("failed to parse from JSON"); + policy.parse(None).expect("failed to convert to policy"); + + // A JSON object will be parsed as a policy in the JSON syntax + let policy_json = json!({ + "effect": "permit", + "principal": { + "op": "==", + "entity": { "type": "User", "id": "alice" } + }, + "action": { + "op": "All" + }, + "resource": { + "op": "All" + }, + "conditions": [] + }); + let policy: Policy = + serde_json::from_value(policy_json).expect("failed to parse from JSON"); + policy.parse(None).expect("failed to convert to policy"); + + // Invalid Cedar syntax + let src = "foo(principal == User::\"alice\", action, resource);"; + let policy: Policy = serde_json::from_value(json!(src)).expect("failed to parse from JSON"); + let err = policy + .parse(None) + .expect_err("should have failed to convert to policy"); + expect_err( + src, + &err, + &ExpectedErrorMessageBuilder::error("failed to parse policy from string") + .source("invalid policy effect: foo") + .exactly_one_underline("foo") + .help("effect must be either `permit` or `forbid`") + .build(), + ); + + // Not a static policy + let src = "permit(principal == ?principal, action, resource);"; + let policy: Policy = + serde_json::from_value(json!(src)).expect("failed to parse from string"); + let err = policy + .parse(None) + .expect_err("should have failed to convert to policy"); + expect_err( + src, + &err, + &ExpectedErrorMessageBuilder::error("failed to parse policy from string") + .source("expected a static policy, got a template containing the slot ?principal") + .exactly_one_underline(src) + .help("try removing the template slot(s) from this policy") + .build(), + ); + + // Not a single policy + let src = "permit(principal == User::\"alice\", action, resource); permit(principal == User::\"bob\", action, resource);"; + let policy: Policy = + serde_json::from_value(json!(src)).expect("failed to parse from string"); + let err = policy + .parse(None) + .expect_err("should have failed to convert to policy"); + expect_err( + src, + &err, + &ExpectedErrorMessageBuilder::error("failed to parse policy from string") + .source("unexpected token `permit`") + .exactly_one_underline("permit") + .build(), + ); + + // Invalid JSON syntax (duplicate keys) + // The error message comes from the `serde(expecting = ..)` annotation on `Policy` + let policy_json_str = r#"{ + "effect": "permit", + "effect": "forbid" + }"#; + let err = serde_json::from_str::(policy_json_str) + .expect_err("should have failed to parse from JSON"); + assert_eq!( + err.to_string(), + "expected a static policy in the Cedar or JSON policy format (with no duplicate keys)" + ); + } + + #[test] + fn test_template_parser() { + // A string literal will be parsed as a template in the Cedar syntax + let template_json = json!("permit(principal == ?principal, action, resource);"); + let template: Template = + serde_json::from_value(template_json).expect("failed to parse from JSON"); + template.parse(None).expect("failed to convert to template"); + + // A JSON object will be parsed as a template in the JSON syntax + let template_json = json!({ + "effect": "permit", + "principal": { + "op": "==", + "slot": "?principal" + }, + "action": { + "op": "All" + }, + "resource": { + "op": "All" + }, + "conditions": [] }); + let template: Template = + serde_json::from_value(template_json).expect("failed to parse from JSON"); + template.parse(None).expect("failed to convert to template"); + + // Invalid syntax + let src = "permit(principal == ?foo, action, resource);"; + let template: Template = + serde_json::from_value(json!(src)).expect("failed to parse from JSON"); + let err = template + .parse(None) + .expect_err("should have failed to convert to template"); + expect_err( + src, + &err, + &ExpectedErrorMessageBuilder::error("failed to parse template from string") + .source("expected an entity uid or matching template slot, found ?foo instead of ?principal") + .exactly_one_underline("?foo") + .build(), + ); + + // Static policies can also be parsed as templates + let template_json = json!("permit(principal == User::\"alice\", action, resource);"); + let template: Template = + serde_json::from_value(template_json).expect("failed to parse from JSON"); + template.parse(None).expect("failed to convert to template"); + } + + #[test] + fn test_static_policy_set_parser() { + // A string literal will be parsed as the `Concatenated` variant + let policies_json = json!("permit(principal == User::\"alice\", action, resource);"); + let policies: StaticPolicySet = + serde_json::from_value(policies_json).expect("failed to parse from JSON"); + policies + .parse() + .expect("failed to convert to static policy set"); + + // A JSON array will be parsed as the `Set` variant + let policies_json = json!([ + { + "effect": "permit", + "principal": { + "op": "==", + "entity": { "type": "User", "id": "alice" } + }, + "action": { + "op": "All" + }, + "resource": { + "op": "All" + }, + "conditions": [] + }, + "permit(principal == User::\"bob\", action, resource);" + ]); + let policies: StaticPolicySet = + serde_json::from_value(policies_json).expect("failed to parse from JSON"); + policies + .parse() + .expect("failed to convert to static policy set"); + + // A JSON object will be parsed as the `Map` variant + let policies_json = json!({ + "policy0": { + "effect": "permit", + "principal": { + "op": "==", + "entity": { "type": "User", "id": "alice" } + }, + "action": { + "op": "All" + }, + "resource": { + "op": "All" + }, + "conditions": [] + }, + "policy1": "permit(principal == User::\"bob\", action, resource);" + }); + let policies: StaticPolicySet = + serde_json::from_value(policies_json).expect("failed to parse from JSON"); + policies + .parse() + .expect("failed to convert to static policy set"); + + // Invalid static policy set - `policy0` is a template + let policies_json = json!({ + "policy0": "permit(principal == ?principal, action, resource);", + "policy1": "permit(principal == User::\"bob\", action, resource);" + }); + let policies: StaticPolicySet = + serde_json::from_value(policies_json).expect("failed to parse from JSON"); + let errs = policies + .parse() + .expect_err("should have failed to convert to static policy set"); + assert_length_matches(&errs, 1); + expect_err( + "permit(principal == ?principal, action, resource);", + &errs[0], + &ExpectedErrorMessageBuilder::error( + "failed to parse policy with id `policy0` from string", + ) + .source("expected a static policy, got a template containing the slot ?principal") + .exactly_one_underline("permit(principal == ?principal, action, resource);") + .help("try removing the template slot(s) from this policy") + .build(), + ); + + // Invalid static policy set - `policy1` is actually multiple policies + let policies_json = json!({ + "policy0": "permit(principal == User::\"alice\", action, resource);", + "policy1": "permit(principal == User::\"bob\", action, resource); permit(principal, action, resource);" + }); + let policies: StaticPolicySet = + serde_json::from_value(policies_json).expect("failed to parse from JSON"); + let errs = policies + .parse() + .expect_err("should have failed to convert to static policy set"); + assert_length_matches(&errs, 1); + expect_err( + "permit(principal == User::\"bob\", action, resource); permit(principal, action, resource);", + &errs[0], + &ExpectedErrorMessageBuilder::error( + "failed to parse policy with id `policy1` from string", + ) + .source("unexpected token `permit`") + .exactly_one_underline("permit") + .build(), + ); + + // Invalid static policy set - both policies are ill-formed + let policies_json = json!({ + "policy0": "permit(principal, action);", + "policy1": "forbid(principal, action);" + }); + let policies: StaticPolicySet = + serde_json::from_value(policies_json).expect("failed to parse from JSON"); + let errs = policies + .parse() + .expect_err("should have failed to convert to static policy set"); + assert_length_matches(&errs, 2); + for err in errs { + // hack to account for nondeterministic error ordering + if err + .to_string() + .contains("failed to parse policy with id `policy0`") + { + expect_err( + "permit(principal, action);", + &err, + &ExpectedErrorMessageBuilder::error( + "failed to parse policy with id `policy0` from string", + ) + .source("this policy is missing the `resource` variable in the scope") + .exactly_one_underline("") + .help("policy scopes must contain a `principal`, `action`, and `resource` element in that order") + .build(), + ); + } else { + expect_err( + "forbid(principal, action);", + &err, + &ExpectedErrorMessageBuilder::error( + "failed to parse policy with id `policy1` from string", + ) + .source("this policy is missing the `resource` variable in the scope") + .exactly_one_underline("") + .help("policy scopes must contain a `principal`, `action`, and `resource` element in that order") + .build(), + ); + } + } + } + + #[test] + fn test_policy_set_parser() { + // Empty policy set + let policies_json = json!({}); + let policies: PolicySet = + serde_json::from_value(policies_json).expect("failed to parse from JSON"); + policies.parse().expect("failed to convert to policy set"); + + // Example valid policy set + let policies_json = json!({ + "staticPolicies": [ + { + "effect": "permit", + "principal": { + "op": "==", + "entity": { "type": "User", "id": "alice" } + }, + "action": { + "op": "All" + }, + "resource": { + "op": "All" + }, + "conditions": [] + }, + "permit(principal == User::\"bob\", action, resource);" + ], + "templates": { + "ID0": "permit(principal == ?principal, action, resource);" + }, + "templateLinks": [ + { + "templateId": "ID0", + "newId": "ID1", + "values": { "?principal": { "type": "User", "id": "charlie" } } + } + ] + }); + let policies: PolicySet = + serde_json::from_value(policies_json).expect("failed to parse from JSON"); + policies.parse().expect("failed to convert to policy set"); + + // Example policy set with a link error - `policy0` is already used + let policies_json = json!({ + "staticPolicies": { + "policy0": "permit(principal == User::\"alice\", action, resource);", + "policy1": "permit(principal == User::\"bob\", action, resource);" + }, + "templates": { + "template": "permit(principal == ?principal, action, resource);" + }, + "templateLinks": [ + { + "templateId": "template", + "newId": "policy0", + "values": { "?principal": { "type": "User", "id": "charlie" } } + } + ] + }); + let policies: PolicySet = + serde_json::from_value(policies_json).expect("failed to parse from JSON"); + let errs = policies + .parse() + .expect_err("should have failed to convert to policy set"); + assert_length_matches(&errs, 1); + expect_err( + "", + &errs[0], + &ExpectedErrorMessageBuilder::error("unable to link template") + .source("template-linked policy id `policy0` conflicts with an existing policy id") + .build(), + ); + } + + #[test] + fn policy_set_parser_is_compatible_with_est_parser() { + // The `PolicySet::parse` function accepts the `est::PolicySet` JSON format + let json = json!({ + "staticPolicies": { + "policy1": { + "effect": "permit", + "principal": { + "op": "==", + "entity": { "type": "User", "id": "alice" } + }, + "action": { + "op": "==", + "entity": { "type": "Action", "id": "view" } + }, + "resource": { + "op": "in", + "entity": { "type": "Folder", "id": "foo" } + }, + "conditions": [] + } + }, + "templates": { + "template": { + "effect" : "permit", + "principal" : { + "op" : "==", + "slot" : "?principal" + }, + "action" : { + "op" : "all" + }, + "resource" : { + "op" : "all", + }, + "conditions": [] + } + }, + "templateLinks" : [ + { + "newId" : "link", + "templateId" : "template", + "values" : { + "?principal" : { "type" : "User", "id" : "bob" } + } + } + ] + }); + + // use `crate::PolicySet::from_json_value` + let ast_from_est = crate::PolicySet::from_json_value(json.clone()) + .expect("failed to convert to policy set"); + + // use `PolicySet::parse` + let ffi_policy_set: PolicySet = + serde_json::from_value(json).expect("failed to parse from JSON"); + let ast_from_ffi = ffi_policy_set + .parse() + .expect("failed to convert to policy set"); + + // check that the produced policy sets match + assert_eq!(ast_from_est, ast_from_ffi); + } + + #[test] + fn test_schema_parser() { + // A string literal will be parsed as a schema in the Cedar syntax + let schema_json = json!("entity User = {name: String};\nentity Photo;\naction viewPhoto appliesTo {principal: User, resource: Photo};"); let schema: Schema = serde_json::from_value(schema_json).expect("failed to parse from JSON"); let _ = schema.parse().expect("failed to convert to schema"); - // JSON syntax + // A JSON object will be parsed as a schema in the JSON syntax let schema_json = json!({ - "json": { - "": { - "entityTypes": { - "User": { - "shape": { - "type": "Record", - "attributes": { - "name": { - "type": "String" - } + "": { + "entityTypes": { + "User": { + "shape": { + "type": "Record", + "attributes": { + "name": { + "type": "String" } } - }, - "Photo": {} + } }, - "actions": { - "viewPhoto": { - "appliesTo": { - "principalTypes": [ "User" ], - "resourceTypes": [ "Photo" ] - } + "Photo": {} + }, + "actions": { + "viewPhoto": { + "appliesTo": { + "principalTypes": [ "User" ], + "resourceTypes": [ "Photo" ] } } } @@ -434,26 +1051,22 @@ mod test { let _ = schema.parse().expect("failed to convert to schema"); // Invalid syntax (the value is a policy) - let schema_json = json!({ - "human": "permit(principal == User::\"alice\", action, resource);" - }); - let schema: Schema = - serde_json::from_value(schema_json).expect("failed to parse from JSON"); + let src = "permit(principal == User::\"alice\", action, resource);"; + let schema: Schema = serde_json::from_value(json!(src)).expect("failed to parse from JSON"); let err = schema .parse() .map(|(s, _)| s) .expect_err("should have failed to convert to schema"); expect_err( - "permit(principal == User::\"alice\", action, resource);", + src, &err, - &ExpectedErrorMessageBuilder::error( - r#"error parsing schema: unexpected token `permit`"#, - ) - .exactly_one_underline_with_label( - "permit", - "expected `action`, `entity`, `namespace`, or `type`", - ) - .build(), + &ExpectedErrorMessageBuilder::error("failed to parse schema from string") + .exactly_one_underline_with_label( + "permit", + "expected `action`, `entity`, `namespace`, or `type`", + ) + .source("error parsing schema: unexpected token `permit`") + .build(), ); } } diff --git a/cedar-policy/src/ffi/validate.rs b/cedar-policy/src/ffi/validate.rs index 78b965258..3197cd0af 100644 --- a/cedar-policy/src/ffi/validate.rs +++ b/cedar-policy/src/ffi/validate.rs @@ -122,7 +122,7 @@ impl ValidationCall { Result<(crate::PolicySet, crate::Schema, ValidationSettings), Vec>, > { let mut errs = vec![]; - let policies = match self.policies.parse(None) { + let policies = match self.policies.parse() { Ok(policies) => policies, Err(e) => { errs.extend(e); @@ -215,15 +215,17 @@ pub enum ValidationAnswer { } // PANIC SAFETY unit tests -#[allow(clippy::panic)] +#[allow(clippy::panic, clippy::indexing_slicing)] #[cfg(test)] mod test { use super::*; + + use crate::ffi::test_utils::*; use cool_asserts::assert_matches; use serde_json::json; - use std::collections::HashMap; - /// Assert that [`validate_json()`] returns Success with no errors + /// Assert that [`validate_json()`] returns [`ValidationAnswer::Success`] + /// with no errors #[track_caller] fn assert_validates_without_errors(json: serde_json::Value) { let ans_val = validate_json(json).unwrap(); @@ -233,33 +235,36 @@ mod test { }); } - /// Assert that [`validate_json()`] returns Success with exactly - /// `expected_num_errors` errors + /// Assert that [`validate_json()`] returns [`ValidationAnswer::Success`] + /// and return the enclosed errors #[track_caller] - fn assert_validates_with_errors(json: serde_json::Value, expected_num_errors: usize) { + fn assert_validates_with_errors(json: serde_json::Value) -> Vec { let ans_val = validate_json(json).unwrap(); assert_matches!(ans_val.get("validationErrors"), Some(_)); // should be present, with this camelCased name assert_matches!(ans_val.get("validationWarnings"), Some(_)); // should be present, with this camelCased name let result: Result = serde_json::from_value(ans_val); assert_matches!(result, Ok(ValidationAnswer::Success { validation_errors, validation_warnings: _, other_warnings: _ }) => { - assert_eq!(validation_errors.len(), expected_num_errors, "actual validation errors were: {validation_errors:?}"); + validation_errors + }) + } + + /// Assert that [`validate_json_str()`] returns a `serde_json::Error` + /// error with a message that matches `msg` + #[track_caller] + fn assert_validate_json_str_is_failure(call: &str, msg: &str) { + assert_matches!(validate_json_str(call), Err(e) => { + assert_eq!(e.to_string(), msg); }); } - /// Assert that [`validate_json()`] returns `ValidationAnswer::Failure` - /// where some error contains the expected error string `err` (in its main - /// error message) + /// Assert that [`validate_json()`] returns [`ValidationAnswer::Failure`] + /// and return the enclosed errors #[track_caller] - fn assert_is_failure(json: serde_json::Value, err: &str) { + fn assert_is_failure(json: serde_json::Value) -> Vec { let ans_val = validate_json(json).expect("expected it to at least parse into ValidationCall"); let result: Result = serde_json::from_value(ans_val); - assert_matches!(result, Ok(ValidationAnswer::Failure { errors, .. }) => { - assert!( - errors.iter().any(|e| e.message.contains(err)), - "Expected to see error(s) containing `{err}`, but saw {errors:?}", - ); - }); + assert_matches!(result, Ok(ValidationAnswer::Failure { errors, .. }) => errors) } #[test] @@ -267,7 +272,7 @@ mod test { let call = ValidationCall { validation_settings: ValidationSettings::default(), schema: Schema::Json(json!({}).into()), - policies: PolicySet::Map(HashMap::new()), + policies: PolicySet::new(), }; assert_validates_without_errors(serde_json::to_value(&call).unwrap()); @@ -275,13 +280,13 @@ mod test { let call = ValidationCall { validation_settings: ValidationSettings::default(), schema: Schema::Human(String::new()), - policies: PolicySet::Map(HashMap::new()), + policies: PolicySet::new(), }; assert_validates_without_errors(serde_json::to_value(&call).unwrap()); let call = json!({ - "schema": { "json": {} }, + "schema": {}, "policies": {} }); @@ -291,7 +296,7 @@ mod test { #[test] fn test_nontrivial_correct_policy_validates_without_errors() { let json = json!({ - "schema": { "json": { "": { + "schema": { "": { "entityTypes": { "User": { "memberOfTypes": [ "UserGroup" ] @@ -333,9 +338,11 @@ mod test { } } } - }}}, + }}, "policies": { - "policy0": "permit(principal in UserGroup::\"alice_friends\", action == Action::\"viewPhoto\", resource);" + "staticPolicies": { + "policy0": "permit(principal in UserGroup::\"alice_friends\", action == Action::\"viewPhoto\", resource);" + } }}); assert_validates_without_errors(json); @@ -344,25 +351,29 @@ mod test { #[test] fn test_policy_with_parse_error_fails_passing_on_errors() { let json = json!({ - "schema": { "json": { "": { + "schema": { "": { "entityTypes": {}, "actions": {} - }}}, + }}, "policies": { - "policy0": "azfghbjknnhbud" + "staticPolicies": { + "policy0": "azfghbjknnhbud" + } } }); - assert_is_failure( - json, - "failed to parse policy with id `policy0`: unexpected end of input", + let errs = assert_is_failure(json); + assert_exactly_one_error( + &errs, + "failed to parse policy with id `policy0` from string: unexpected end of input", + None, ); } #[test] fn test_semantically_incorrect_policy_fails_with_errors() { let json = json!({ - "schema": { "json": { "": { + "schema": { "": { "entityTypes": { "User": { "memberOfTypes": [ ] @@ -379,19 +390,39 @@ mod test { } } } - }}}, + }}, "policies": { - "policy0": "permit(principal == Photo::\"photo.jpg\", action == Action::\"viewPhoto\", resource == User::\"alice\");", - "policy1": "permit(principal == Photo::\"photo2.jpg\", action == Action::\"viewPhoto\", resource == User::\"alice2\");" + "staticPolicies": { + "policy0": "permit(principal == Photo::\"photo.jpg\", action == Action::\"viewPhoto\", resource == User::\"alice\");", + "policy1": "permit(principal == Photo::\"photo2.jpg\", action == Action::\"viewPhoto\", resource == User::\"alice2\");" + } }}); - assert_validates_with_errors(json, 2); + let errs = assert_validates_with_errors(json); + assert_length_matches(&errs, 2); + for err in errs { + if err.policy_id == PolicyId::new("policy0") { + assert_error_matches( + &err.error, + "for policy `policy0`, unable to find an applicable action given the policy scope constraints", + None + ); + } else if err.policy_id == PolicyId::new("policy1") { + assert_error_matches( + &err.error, + "for policy `policy1`, unable to find an applicable action given the policy scope constraints", + None + ); + } else { + panic!("unexpected validation error: {err:?}"); + } + } } #[test] fn test_nontrivial_correct_policy_validates_without_errors_concatenated_policies() { let json = json!({ - "schema": { "json": { "": { + "schema": { "": { "entityTypes": { "User": { "memberOfTypes": [ "UserGroup" ] @@ -433,9 +464,11 @@ mod test { } } } - }}}, + }}, "policies": { - "policy0": "permit(principal in UserGroup::\"alice_friends\", action == Action::\"viewPhoto\", resource);" + "staticPolicies": { + "policy0": "permit(principal in UserGroup::\"alice_friends\", action == Action::\"viewPhoto\", resource);" + } } }); @@ -445,23 +478,27 @@ mod test { #[test] fn test_policy_with_parse_error_fails_passing_on_errors_concatenated_policies() { let json = json!({ - "schema": { "json": { "": { + "schema": { "": { "entityTypes": {}, "actions": {} - }}}, - "policies": "azfghbjknnhbud" + }}, + "policies": { + "staticPolicies": "azfghbjknnhbud" + } }); - assert_is_failure( - json, + let errs = assert_is_failure(json); + assert_exactly_one_error( + &errs, "failed to parse policies from string: unexpected end of input", + None, ); } #[test] fn test_semantically_incorrect_policy_fails_with_errors_concatenated_policies() { let json = json!({ - "schema": { "json": { "": { + "schema": { "": { "entityTypes": { "User": { "memberOfTypes": [ ] @@ -478,26 +515,39 @@ mod test { } } } - }}}, - "policies": "forbid(principal, action, resource);permit(principal == Photo::\"photo.jpg\", action == Action::\"viewPhoto\", resource == User::\"alice\");" + }}, + "policies": { + "staticPolicies": "forbid(principal, action, resource);permit(principal == Photo::\"photo.jpg\", action == Action::\"viewPhoto\", resource == User::\"alice\");" + } }); - assert_validates_with_errors(json, 1); + let errs = assert_validates_with_errors(json); + assert_length_matches(&errs, 1); + assert_eq!(errs[0].policy_id, PolicyId::new("policy1")); + assert_error_matches( + &errs[0].error, + "for policy `policy1`, unable to find an applicable action given the policy scope constraints", + None + ); } #[test] fn test_policy_with_parse_error_fails_concatenated_policies() { let json = json!({ - "schema": { "json": { "": { + "schema": { "": { "entityTypes": {}, "actions": {} - }}}, - "policies": "permit(principal, action, resource);forbid" + }}, + "policies": { + "staticPolicies": "permit(principal, action, resource);forbid" + } }); - assert_is_failure( - json, + let errs = assert_is_failure(json); + assert_exactly_one_error( + &errs, "failed to parse policies from string: unexpected end of input", + None, ); } @@ -510,31 +560,134 @@ mod test { #[test] fn test_validate_fails_on_duplicate_namespace() { - let json = r#"{ - "schema": { "json": { + let text = r#"{ + "schema": { "foo": { "entityTypes": {}, "actions": {} }, "foo": { "entityTypes": {}, "actions": {} } - }}, - "policies": "" + }, + "policies": {} }"#; - assert_matches!(validate_json_str(json), Err(e) => { - assert!(e.to_string().contains("the key `foo` occurs two or more times in the same JSON object"), "actual error message was {e}"); - }); + assert_validate_json_str_is_failure( + text, + "expected a schema in the Cedar or JSON policy format (with no duplicate keys) at line 5 column 13", + ); } #[test] fn test_validate_fails_on_duplicate_policy_id() { - let json = r#"{ - "schema": { "json": { "": { "entityTypes": {}, "actions": {} } } }, + let text = r#"{ + "schema": { "": { "entityTypes": {}, "actions": {} } }, "policies": { - "ID0": "permit(principal, action, resource);", - "ID0": "permit(principal, action, resource);" + "staticPolicies": { + "ID0": "permit(principal, action, resource);", + "ID0": "permit(principal, action, resource);" + } } }"#; - assert_matches!(validate_json_str(json), Err(e) => { - assert!(e.to_string().contains("policies as a concatenated string or multiple policies as a hashmap where the policy id is the key"), "actual error message was {e}"); + assert_validate_json_str_is_failure( + text, + "expected a static policy set represented by a string, JSON array, or JSON object (with no duplicate keys) at line 8 column 13", + ); + } + + #[test] + fn test_validate_with_templates() { + // Successful validation with templates and template links + let json = json!({ + "schema": "entity User, Photo; action viewPhoto appliesTo { principal: User, resource: Photo };", + "policies": { + "staticPolicies": { + "ID0": "permit(principal == User::\"alice\", action, resource);" + }, + "templates": { + "ID1": "permit(principal == ?principal, action, resource);" + }, + "templateLinks": [{ + "templateId": "ID1", + "newId": "ID2", + "values": { + "?principal": { "type": "User", "id": "bob" } + } + }] + } + }); + assert_validates_without_errors(json); + + // Validation fails due to bad template + let json = json!({ + "schema": "entity User, Photo; action viewPhoto appliesTo { principal: User, resource: Photo };", + "policies": { + "staticPolicies": { + "ID0": "permit(principal == User::\"alice\", action, resource);" + }, + "templates": { + "ID1": "permit(principal == ?principal, action == Action::\"foo\", resource);" + }, + "templateLinks": [{ + "templateId": "ID1", + "newId": "ID2", + "values": { + "?principal": { "type": "User", "id": "bob" } + } + }] + } + }); + let errs = assert_validates_with_errors(json); + assert_length_matches(&errs, 3); + for err in errs { + if err.policy_id == PolicyId::new("ID1") { + if err.error.message.contains("unrecognized action") { + assert_error_matches( + &err.error, + "for policy `ID1`, unrecognized action `Action::\"foo\"`", + Some("did you mean `Action::\"viewPhoto\"`?"), + ); + } else { + assert_error_matches( + &err.error, + "for policy `ID1`, unable to find an applicable action given the policy scope constraints", + None, + ); + } + } else if err.policy_id == PolicyId::new("ID2") { + assert_error_matches( + &err.error, + "for policy `ID2`, unable to find an applicable action given the policy scope constraints", + None, + ); + } else { + panic!("unexpected validation error: {err:?}"); + } + } + + // Validation fails due to bad link + let json = json!({ + "schema": "entity User, Photo; action viewPhoto appliesTo { principal: User, resource: Photo };", + "policies": { + "staticPolicies": { + "ID0": "permit(principal == User::\"alice\", action, resource);" + }, + "templates": { + "ID1": "permit(principal == ?principal, action, resource);" + }, + "templateLinks": [{ + "templateId": "ID1", + "newId": "ID2", + "values": { + "?principal": { "type": "Photo", "id": "bob" } + } + }] + } }); + let errs = assert_validates_with_errors(json); + assert_length_matches(&errs, 1); + assert_eq!(errs[0].policy_id, PolicyId::new("ID2")); + assert_error_matches( + &errs[0].error, + "for policy `ID2`, unable to find an applicable action given the policy scope constraints", + None + ); } }