use cedar_policy::{
Authorizer, Context, Entities, Entity, EntityId, EntityTypeName, EntityUid, PolicySet, Request,
RestrictedExpression,
};
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use thiserror::Error;
use crate::decision::{PolicyDecision, PolicyOutcome};
use crate::types::{ContextIn, DecideRequest};
#[derive(Debug, Error)]
pub enum EngineError {
#[error("policy parse failed: {0}")]
PolicyParse(String),
#[error("request build failed: {0}")]
RequestBuild(String),
#[error("context build failed: {0}")]
ContextBuild(String),
#[error("entity build failed: {0}")]
EntityBuild(String),
}
pub struct PolicyEngine {
policies: PolicySet,
auth: Authorizer,
}
impl PolicyEngine {
pub fn from_policy_str(policy_text: &str) -> Result<Self, EngineError> {
let ps: PolicySet = policy_text
.parse()
.map_err(|err| EngineError::PolicyParse(format!("{err:?}")))?;
Ok(Self {
policies: ps,
auth: Authorizer::new(),
})
}
pub fn evaluate(&self, req: &DecideRequest) -> Result<PolicyDecision, EngineError> {
let ctx = req.context.clone().unwrap_or_default();
let p_type = EntityTypeName::from_str("Suggestor::Persona")
.map_err(|e| EngineError::EntityBuild(e.to_string()))?;
let p_id = EntityId::from_str(&req.principal.id)
.map_err(|e| EngineError::EntityBuild(e.to_string()))?;
let p_uid = EntityUid::from_type_name_and_id(p_type, p_id);
let p_attrs: HashMap<String, RestrictedExpression> = HashMap::from([
(
"authority".to_string(),
RestrictedExpression::new_string(req.principal.authority.clone()),
),
(
"policy_version".to_string(),
RestrictedExpression::new_string(
req.principal.policy_version.clone().unwrap_or_default(),
),
),
(
"domains".to_string(),
string_set(req.principal.domains.clone()),
),
]);
let principal_entity = Entity::new(p_uid.clone(), p_attrs, HashSet::new());
let r_type = EntityTypeName::from_str("Flow::Commitment")
.map_err(|e| EngineError::EntityBuild(e.to_string()))?;
let r_id = EntityId::from_str(&req.resource.id)
.map_err(|e| EngineError::EntityBuild(e.to_string()))?;
let r_uid = EntityUid::from_type_name_and_id(r_type, r_id);
let r_attrs: HashMap<String, RestrictedExpression> = HashMap::from([
(
"resource_type".to_string(),
RestrictedExpression::new_string(
req.resource.resource_type.clone().unwrap_or_default(),
),
),
(
"phase".to_string(),
RestrictedExpression::new_string(req.resource.phase.clone().unwrap_or_default()),
),
(
"gates_passed".to_string(),
string_set(req.resource.gates_passed.clone().unwrap_or_default()),
),
]);
let resource_entity = Entity::new(r_uid.clone(), r_attrs, HashSet::new());
let entities = Entities::from_entities([principal_entity, resource_entity])
.map_err(|e| EngineError::EntityBuild(e.to_string()))?;
let ctx_json = serde_json::json!({
"commitment_type": ctx.commitment_type.clone().unwrap_or_default(),
"amount": ctx.amount.unwrap_or(0),
"human_approval_present": ctx.human_approval_present.unwrap_or(false),
"required_gates_met": ctx.required_gates_met.unwrap_or(false),
"principal_domains": req.principal.domains.clone(),
"gates_passed": req.resource.gates_passed.clone().unwrap_or_default(),
});
let context = Context::from_json_value(ctx_json, None)
.map_err(|e| EngineError::ContextBuild(e.to_string()))?;
let action_uid: EntityUid = format!("Action::\"{}\"", req.action)
.parse()
.map_err(|e: cedar_policy::ParseErrors| EngineError::RequestBuild(e.to_string()))?;
let request = Request::new(Some(p_uid), Some(action_uid), Some(r_uid), context);
let response = self.auth.is_authorized(&request, &self.policies, &entities);
let cedar_decision = response.decision();
let outcome = match cedar_decision {
cedar_policy::Decision::Allow => PolicyOutcome::Promote,
cedar_policy::Decision::Deny => {
if should_escalate(&req.action, &req.principal.authority, &ctx) {
PolicyOutcome::Escalate
} else {
PolicyOutcome::Reject
}
}
};
let reasons: Vec<String> = response
.diagnostics()
.reason()
.map(std::string::ToString::to_string)
.collect();
let reason = if reasons.is_empty() {
None
} else {
Some(reasons.join(", "))
};
Ok(PolicyDecision::policy(
outcome,
reason,
req.principal.id.clone(),
req.action.clone(),
req.resource.id.clone(),
))
}
}
fn string_set(values: Vec<String>) -> RestrictedExpression {
RestrictedExpression::new_set(values.into_iter().map(RestrictedExpression::new_string))
}
fn should_escalate(action: &str, authority: &str, ctx: &ContextIn) -> bool {
let commitment_actions = ["commit", "promote"];
let escalatable_authorities = ["supervisory", "participatory"];
commitment_actions.contains(&action)
&& escalatable_authorities.contains(&authority)
&& !ctx.human_approval_present.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{PrincipalIn, ResourceIn};
fn test_engine() -> PolicyEngine {
let policy = std::fs::read_to_string("policies/policy.cedar")
.expect("policy file should exist in test working dir");
PolicyEngine::from_policy_str(&policy).expect("policy should parse")
}
fn make_request(
authority: &str,
action: &str,
amount: i64,
human_approval: bool,
) -> DecideRequest {
DecideRequest {
principal: PrincipalIn {
id: "agent:test".into(),
authority: authority.into(),
domains: vec!["test".into()],
policy_version: None,
},
resource: ResourceIn {
id: "flow:test-001".into(),
resource_type: Some("quote".into()),
phase: Some("convergence".into()),
gates_passed: Some(vec!["evidence".into()]),
},
action: action.into(),
context: Some(ContextIn {
commitment_type: Some("quote".into()),
amount: Some(amount),
human_approval_present: Some(human_approval),
required_gates_met: Some(true),
}),
delegation_b64: None,
}
}
#[test]
fn advisory_can_propose() {
let engine = test_engine();
let req = make_request("advisory", "propose", 5000, false);
let decision = engine.evaluate(&req).unwrap();
assert_eq!(decision.outcome, PolicyOutcome::Promote);
}
#[test]
fn advisory_cannot_commit() {
let engine = test_engine();
let req = make_request("advisory", "commit", 5000, false);
let decision = engine.evaluate(&req).unwrap();
assert_ne!(decision.outcome, PolicyOutcome::Promote);
}
#[test]
fn supervisory_can_commit_with_approval() {
let engine = test_engine();
let req = make_request("supervisory", "commit", 25000, true);
let decision = engine.evaluate(&req).unwrap();
assert_eq!(decision.outcome, PolicyOutcome::Promote);
}
#[test]
fn supervisory_escalates_without_approval() {
let engine = test_engine();
let req = make_request("supervisory", "commit", 25000, false);
let decision = engine.evaluate(&req).unwrap();
assert_eq!(decision.outcome, PolicyOutcome::Escalate);
}
#[test]
fn sovereign_can_commit_autonomously() {
let engine = test_engine();
let req = make_request("sovereign", "commit", 25000, false);
let decision = engine.evaluate(&req).unwrap();
assert_eq!(decision.outcome, PolicyOutcome::Promote);
}
}