diff --git a/cedar-policy-core/src/tpe.rs b/cedar-policy-core/src/tpe.rs index ca5560aeb8..655e7babfd 100644 --- a/cedar-policy-core/src/tpe.rs +++ b/cedar-policy-core/src/tpe.rs @@ -457,36 +457,36 @@ when { principal in resource.editors }; .static_policies() .find(|p| matches!(p.annotation(&id), Some(Annotation {val, ..}) if val == "3")) .unwrap(); - let false_permits: HashSet = residuals + let false_permits: HashSet<&PolicyID> = residuals .false_permits() .map(|p| p.get_policy_id()) .collect(); assert!(false_permits.len() == 2); assert!(false_permits.contains(policy0.id())); assert!(false_permits.contains(policy3.id())); - let false_forbids: HashSet = residuals + let false_forbids: HashSet<&PolicyID> = residuals .false_forbids() .map(|p| p.get_policy_id()) .collect(); assert!(false_forbids.is_empty()); - let true_permits: HashSet = residuals + let true_permits: HashSet<&PolicyID> = residuals .satisfied_permits() .map(|p| p.get_policy_id()) .collect(); assert!(true_permits.is_empty()); - let true_forbids: HashSet = residuals + let true_forbids: HashSet<&PolicyID> = residuals .satisfied_forbids() .map(|p| p.get_policy_id()) .collect(); assert!(true_forbids.is_empty()); - let non_trivial_permits: HashSet = residuals + let non_trivial_permits: HashSet<&PolicyID> = residuals .residual_permits() .map(|p| p.get_policy_id()) .collect(); assert!(non_trivial_permits.len() == 2); assert!(non_trivial_permits.contains(policy1.id())); assert!(non_trivial_permits.contains(policy2.id())); - let non_trivial_forbids: HashSet = residuals + let non_trivial_forbids: HashSet<&PolicyID> = residuals .residual_forbids() .map(|p| p.get_policy_id()) .collect(); diff --git a/cedar-policy-core/src/tpe/response.rs b/cedar-policy-core/src/tpe/response.rs index 253feb79b8..b78df1900c 100644 --- a/cedar-policy-core/src/tpe/response.rs +++ b/cedar-policy-core/src/tpe/response.rs @@ -59,8 +59,8 @@ impl ResidualPolicy { } /// Get the [`PolicyID`] - pub fn get_policy_id(&self) -> PolicyID { - self.policy.id().clone() + pub fn get_policy_id(&self) -> &PolicyID { + self.policy.id() } /// All literal uids referenced by this residual @@ -87,16 +87,20 @@ impl From for Policy { pub struct Response<'a> { decision: Option, residuals: HashMap, - // All of the [`Effect::Permit`] policies that were satisfied - satisfied_permits: HashSet, - // All of the [`Effect::Permit`] policies that were not satisfied + // All of the [`Effect::Permit`] policies that were true + true_permits: HashSet, + // All of the [`Effect::Permit`] policies that were false false_permits: HashSet, + // All of the [`Effect::Permit`] policies that errored + error_permits: HashSet, // All of the [`Effect::Permit`] policies that evaluated to a residual residual_permits: HashSet, - // All of the [`Effect::Forbid`] policies that were satisfied - satisfied_forbids: HashSet, - // All of the [`Effect::Forbid`] policies that were not satisfied + // All of the [`Effect::Forbid`] policies that were true + true_forbids: HashSet, + // All of the [`Effect::Forbid`] policies that were false false_forbids: HashSet, + // All of the [`Effect::Forbid`] policies that errored + error_forbids: HashSet, // All of the [`Effect::Forbid`] policies that evaluated to a residual residual_forbids: HashSet, // request used for this partial evaluation @@ -117,11 +121,13 @@ impl<'a> Response<'a> { schema: &'a ValidatorSchema, ) -> Self { let mut residual_map = HashMap::new(); - let mut satisfied_permits = HashSet::new(); + let mut true_permits = HashSet::new(); let mut false_permits = HashSet::new(); + let mut error_permits = HashSet::new(); let mut residual_permits = HashSet::new(); - let mut satisfied_forbids = HashSet::new(); + let mut true_forbids = HashSet::new(); let mut false_forbids = HashSet::new(); + let mut error_forbids = HashSet::new(); let mut residual_forbids = HashSet::new(); for rp in residuals { let r = rp.get_residual(); @@ -130,28 +136,32 @@ impl<'a> Response<'a> { match rp.get_effect() { Effect::Forbid => { if r.is_true() { - satisfied_forbids.insert(id); - } else if r.is_false() || r.is_error() { - false_forbids.insert(id); + true_forbids.insert(id.clone()); + } else if r.is_false() { + false_forbids.insert(id.clone()); + } else if r.is_error() { + error_forbids.insert(id.clone()); } else { - residual_forbids.insert(id); + residual_forbids.insert(id.clone()); } } Effect::Permit => { if r.is_true() { - satisfied_permits.insert(id); - } else if r.is_false() || r.is_error() { - false_permits.insert(id); + true_permits.insert(id.clone()); + } else if r.is_false() { + false_permits.insert(id.clone()); + } else if r.is_error() { + error_permits.insert(id.clone()); } else { - residual_permits.insert(id); + residual_permits.insert(id.clone()); } } } } let decision = match ( - !satisfied_forbids.is_empty(), - !satisfied_permits.is_empty(), + !true_forbids.is_empty(), + !true_permits.is_empty(), !residual_permits.is_empty(), !residual_forbids.is_empty(), ) { @@ -170,11 +180,13 @@ impl<'a> Response<'a> { Self { decision, residuals: residual_map, - satisfied_permits, + true_permits, false_permits, + error_permits, residual_permits, - satisfied_forbids, + true_forbids, false_forbids, + error_forbids, residual_forbids, request, entities, @@ -188,7 +200,7 @@ impl<'a> Response<'a> { clippy::unwrap_used, reason = "we know that the policy ids are in the residuals map" )] - self.satisfied_permits + self.true_permits .iter() .map(|id| self.residuals.get(id).unwrap()) } @@ -199,7 +211,7 @@ impl<'a> Response<'a> { clippy::unwrap_used, reason = "we know that the policy ids are in the residuals map" )] - self.satisfied_forbids + self.true_forbids .iter() .map(|id| self.residuals.get(id).unwrap()) } @@ -215,6 +227,17 @@ impl<'a> Response<'a> { .map(|id| self.residuals.get(id).unwrap()) } + /// Get trivially erroring permit residual policies + pub fn error_permits(&self) -> impl Iterator { + #[expect( + clippy::unwrap_used, + reason = "we know that the policy ids are in the residuals map" + )] + self.error_permits + .iter() + .map(|id| self.residuals.get(id).unwrap()) + } + /// Get trivially false forbid residual policies pub fn false_forbids(&self) -> impl Iterator { #[expect( @@ -226,6 +249,17 @@ impl<'a> Response<'a> { .map(|id| self.residuals.get(id).unwrap()) } + /// Get trivially erroring forbid residual policies + pub fn error_forbids(&self) -> impl Iterator { + #[expect( + clippy::unwrap_used, + reason = "we know that the policy ids are in the residuals map" + )] + self.error_forbids + .iter() + .map(|id| self.residuals.get(id).unwrap()) + } + /// Get non-trivial permit residual policies pub fn residual_permits(&self) -> impl Iterator { #[expect( @@ -258,6 +292,14 @@ impl<'a> Response<'a> { self.decision } + /// Get the determining policies for the authorization decision + pub fn reason(&self) -> Option> { + match self.decision? { + Decision::Allow => Some(self.true_permits.iter()), + Decision::Deny => Some(self.true_forbids.iter()), + } + } + /// Perform reauthorization pub fn reauthorize( &self, diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md index 9cd0eeccd6..9e322764ee 100644 --- a/cedar-policy/CHANGELOG.md +++ b/cedar-policy/CHANGELOG.md @@ -14,6 +14,7 @@ Starting with version 3.2.4, changes marked with a star (*) are _language breaki ### Added - Public syntax tree (`pst`) support for `variadic-is-in-range` feature: a variadic `isInRange` is modelled by a `pst::Expr::VariadicOp{...}` in the PST (#2380). +- Functions for inspecting TPE policy evaluation results (`TpeResponse::reason` and iterators for true/false/error/residual permit/forbid policy IDs). ### Changed diff --git a/cedar-policy/src/api/tpe.rs b/cedar-policy/src/api/tpe.rs index e3e4ee4010..f91906edb8 100644 --- a/cedar-policy/src/api/tpe.rs +++ b/cedar-policy/src/api/tpe.rs @@ -32,8 +32,9 @@ use smol_str::SmolStr; use crate::{ api, tpe_err, Authorizer, Context, Entities, Entity, EntityId, EntityTypeName, EntityUid, - PartialEntityError, PartialRequestCreationError, PermissionQueryError, Policy, PolicySet, - Request, RequestValidationError, RestrictedExpression, Schema, TpeReauthorizationError, + PartialEntityError, PartialRequestCreationError, PermissionQueryError, Policy, PolicyId, + PolicySet, Request, RequestValidationError, RestrictedExpression, Schema, + TpeReauthorizationError, }; /// A partial [`EntityUid`]. @@ -384,7 +385,136 @@ impl TpeResponse<'_> { self.0.decision() } - /// Perform reauthorization + /// Get the determining policies for the partial authorization decision. + /// These are a subset of the determining policies in the response returned + /// after calling [`TpeResponse::reauthorize`] with a concrete request and entities. + /// + /// When [`TpeResponse::decision`] returns a concrete allow or deny, the + /// determining policies returned by this function are exactly the policies from + /// [`TpeResponse::true_permits`] or [`TpeResponse::true_forbids`] respectively. + /// + /// If partial authorization does not reach a decision, then this function + /// returns `None`. It's reasonable to treat this response as "no known + /// determining policies", in which case you can call this function as + /// `response.reason().into_iter().flatten()`. + pub fn reason(&self) -> Option> { + Some(self.0.reason()?.map(PolicyId::ref_cast)) + } + + /// Get the permit policies that did not reach a concrete value or error for the partial request. + /// + /// This function only returns the `PolicyId`s for residual policies. + /// To access the residual policy conditions, use [`TpeResponse::nontrivial_residual_policies`]. + /// + /// These policies could be determining policies _if_ the eventual + /// concrete authorization decision is `Allow` _and_ they are satisfied by + /// the concrete request. If the [`TpeResponse::decision`] is `Deny`, then + /// they cannot be determining. + pub fn residual_permits(&self) -> impl Iterator { + self.0 + .residual_permits() + .map(|rp| PolicyId::ref_cast(rp.get_policy_id())) + } + + /// Get the permit policies that are concretely satisfied by the partial request. + /// + /// To properly interpret the ids returned from this function you need to + /// consider them in the context of [`TpeResponse::decision`]: + /// * For a concrete `Allow` decision, these are a subset of the concrete + /// determining policies and are exactly the policies returned by + /// [`TpeResponse::reason`]. + /// * For a concrete `Deny` decision, these are not determining policies. The + /// iterator may be empty if no permits were satisfied, or it may contain + /// satisfied permits which have been overridden by at least one satisfied + /// forbid policy. + /// * For an unknown decision, these will be a subset of the determining + /// policies _if_ the eventual concrete authorization decision is `Allow`, + /// but they may still be overridden by any non-trivial residual forbid + /// policy. + pub fn true_permits(&self) -> impl Iterator { + self.0 + .satisfied_permits() + .map(|rp| PolicyId::ref_cast(rp.get_policy_id())) + } + + /// Get the permit policies that are concretely not satisfied by the partial request. + /// + /// These policies evaluate to `false`, so they have no impact on the + /// partial authorization decision or on any subsequent concrete decision + /// after reauthorization. + pub fn false_permits(&self) -> impl Iterator { + self.0 + .false_permits() + .map(|rp| PolicyId::ref_cast(rp.get_policy_id())) + } + + /// Get the permit policies that encountered concrete errors for the partial request. + /// + /// These policies errored, so they have no impact on the partial + /// authorization decision. Erroring policies are not generally expected + /// since partial evaluation works only on _validated_ policies, but it is still + /// possible to encounter errors, e.g., on integer overflow. + pub fn error_permits(&self) -> impl Iterator { + self.0 + .error_permits() + .map(|rp| PolicyId::ref_cast(rp.get_policy_id())) + } + + /// Get the forbid policies that did not reach a concrete value or error for the partial request. + /// + /// This function only returns the `PolicyId`s for residual policies. + /// To access the residual policy conditions, use [`TpeResponse::nontrivial_residual_policies`]. + /// + /// The presence of any residual forbids means that [`TpeResponse::decision`] _cannot_ return + /// a concrete `Allow` decision. We do not have enough information to say that these forbid + /// policies do not apply, so they might still override any satisfied permit policies. + pub fn residual_forbids(&self) -> impl Iterator { + self.0 + .residual_forbids() + .map(|rp| PolicyId::ref_cast(rp.get_policy_id())) + } + + /// Get the forbid policies that are concretely satisfied by the partial request. + /// + /// Presence of any satisfied forbids guarantees that they are exactly the + /// policies returned by [`TpeResponse::reason`] and that [`TpeResponse::decision`] + /// must return a concrete `Deny`. + pub fn true_forbids(&self) -> impl Iterator { + self.0 + .satisfied_forbids() + .map(|rp| PolicyId::ref_cast(rp.get_policy_id())) + } + + /// Get the forbid policies that are concretely not satisfied by the partial request. + /// + /// These policies evaluate to `false`, so they have no impact on the + /// partial authorization decision or on any subsequent concrete decision + /// after reauthorization. + pub fn false_forbids(&self) -> impl Iterator { + self.0 + .false_forbids() + .map(|rp| PolicyId::ref_cast(rp.get_policy_id())) + } + + /// Get the forbid policies that encountered concrete errors for the partial request. + /// + /// These policies errored, so they have no impact on the partial + /// authorization decision. Erroring policies are not generally expected + /// since partial evaluation works only on _validated_ policies, but it is still + /// possible to encounter errors, e.g., on integer overflow. + pub fn error_forbids(&self) -> impl Iterator { + self.0 + .error_forbids() + .map(|rp| PolicyId::ref_cast(rp.get_policy_id())) + } + + /// Perform reauthorization, taking the residual policies and further + /// evaluating them with a concrete request and entities. + /// + /// If [`TpeResponse::decision`] returns a decision, then reauthorization + /// will always reach the same decision. If it does not, then this function + /// allows you to provide any data omitted from the partial request in order + /// to reach a concrete decision. pub fn reauthorize( &self, request: &Request, @@ -1259,6 +1389,7 @@ unless ); assert_eq!(response.decision(), None); + assert!(response.reason().is_none()); let request = Request::new( EntityUid::from_type_name_and_id( @@ -2115,8 +2246,8 @@ when { principal in resource.admins }; use itertools::Itertools; use crate::{ - Context, Entities, PartialEntities, PartialEntityUid, PartialRequest, PolicySet, - PrincipalQueryRequest, ResourceQueryRequest, Schema, + Context, Entities, PartialEntities, PartialEntityUid, PartialRequest, PolicyId, + PolicySet, PrincipalQueryRequest, ResourceQueryRequest, Schema, }; use std::{i64, str::FromStr}; @@ -2153,6 +2284,10 @@ when { principal in resource.admins }; .tpe(&req, &partial_entities, &schema) .unwrap(); assert_eq!(response.decision(), Some(Decision::Allow)); + assert_eq!( + response.reason().unwrap().collect::>(), + vec![&PolicyId::new("policy0")] + ); } #[test] @@ -2214,6 +2349,14 @@ when { principal in resource.admins }; .tpe(&req, &partial_entities, &schema) .unwrap(); assert_eq!(response.decision(), Some(Decision::Deny)); + assert_eq!( + response.reason().unwrap().collect::>(), + vec![&PolicyId::new("policy0")] + ); + assert_eq!( + response.true_forbids().collect::>(), + vec![&PolicyId::new("policy0")] + ); } #[test] @@ -2278,6 +2421,10 @@ when { principal in resource.admins }; .tpe(&req, &partial_entities, &schema) .unwrap(); assert_eq!(response.decision(), Some(Decision::Deny)); + assert_eq!( + response.reason().unwrap().collect::>(), + Vec::<&PolicyId>::new() + ); } #[test] @@ -2345,6 +2492,10 @@ when { principal in resource.admins }; .tpe(&req, &partial_entities, &schema) .unwrap(); assert_eq!(response.decision(), Some(Decision::Deny)); + assert_eq!( + response.reason().unwrap().collect::>(), + Vec::<&PolicyId>::new() + ); } #[test] @@ -2390,6 +2541,90 @@ when { principal in resource.admins }; } } + mod response_iterators { + use std::{i64, str::FromStr}; + + use cedar_policy_core::authorizer::Decision; + + use crate::{ + PartialEntities, PartialEntityUid, PartialRequest, PolicyId, PolicySet, Schema, + }; + + #[test] + fn all_policy_categories() { + let schema = Schema::from_str( + "entity P, R; action A appliesTo { principal: P, resource: R, context: { flag: Bool } };", + ) + .unwrap(); + let req = PartialRequest::new( + PartialEntityUid::new("P".parse().unwrap(), None), + r#"Action::"A""#.parse().unwrap(), + PartialEntityUid::new("R".parse().unwrap(), None), + None, + &schema, + ) + .unwrap(); + + let policies = PolicySet::from_str(&format!( + r#" + permit(principal, action, resource); + permit(principal, action, resource) when {{ false }}; + permit(principal, action, resource) when {{ ({} + 1) == 0 || true }}; + permit(principal, action, resource) when {{ context.flag }}; + forbid(principal, action, resource); + forbid(principal, action, resource) when {{ false }}; + forbid(principal, action, resource) when {{ ({} + 1) == 0 || true }}; + forbid(principal, action, resource) when {{ context.flag }}; + "#, + i64::MAX, + i64::MAX + )) + .unwrap(); + + let entities = PartialEntities::empty(); + let response = policies.tpe(&req, &entities, &schema).unwrap(); + + assert_eq!(response.decision(), Some(Decision::Deny)); + assert_eq!( + response.reason().unwrap().collect::>(), + vec![&PolicyId::new("policy4")] + ); + + assert_eq!( + response.true_permits().collect::>(), + vec![&PolicyId::new("policy0")] + ); + assert_eq!( + response.false_permits().collect::>(), + vec![&PolicyId::new("policy1")] + ); + assert_eq!( + response.error_permits().collect::>(), + vec![&PolicyId::new("policy2")] + ); + assert_eq!( + response.residual_permits().collect::>(), + vec![&PolicyId::new("policy3")] + ); + assert_eq!( + response.true_forbids().collect::>(), + vec![&PolicyId::new("policy4")] + ); + assert_eq!( + response.false_forbids().collect::>(), + vec![&PolicyId::new("policy5")] + ); + assert_eq!( + response.error_forbids().collect::>(), + vec![&PolicyId::new("policy6")] + ); + assert_eq!( + response.residual_forbids().collect::>(), + vec![&PolicyId::new("policy7")] + ); + } + } + mod query_action { use cedar_policy_core::authorizer::Decision; @@ -2877,6 +3112,14 @@ when { principal in resource.admins }; let response = policies.tpe(&request, &es, &schema).unwrap(); assert_eq!(response.decision(), Some(Decision::Allow)); + assert_eq!( + response.reason().unwrap().collect::>(), + vec![&PolicyId::new("l")] + ); + assert_eq!( + response.true_permits().collect::>(), + vec![&PolicyId::new("l")] + ); } #[test] @@ -2889,6 +3132,10 @@ when { principal in resource.admins }; let response = policies.tpe(&request, &es, &schema).unwrap(); assert_eq!(response.decision(), Some(Decision::Deny)); + assert_eq!( + response.reason().unwrap().collect::>(), + Vec::<&PolicyId>::new() + ); } #[test] @@ -2911,6 +3158,10 @@ when { principal in resource.admins }; let response = policies.tpe(&request, &es, &schema).unwrap(); assert_eq!(response.decision(), Some(Decision::Deny)); + assert_eq!( + response.reason().unwrap().collect::>(), + Vec::<&PolicyId>::new() + ); } #[test] @@ -2943,6 +3194,7 @@ when { principal in resource.admins }; let residuals: Vec<_> = response.nontrivial_residual_policies().collect(); assert_eq!(residuals[0].to_pst().unwrap().body(), expected.body()); assert_eq!(response.decision(), None); + assert!(response.reason().is_none()); assert_eq!(residuals.len(), 1); } }