Skip to main content

converge_policy/
engine.rs

1//! Cedar policy evaluation engine.
2//!
3//! Wraps the Cedar authorizer with Converge-specific entity mapping.
4//! All decision-relevant data is passed through the Cedar Context as JSON,
5//! keeping entity construction minimal.
6
7use cedar_policy::{
8    Authorizer, Context, Entities, Entity, EntityId, EntityTypeName, EntityUid, PolicySet, Request,
9    RestrictedExpression,
10};
11use std::collections::{HashMap, HashSet};
12use std::str::FromStr;
13use thiserror::Error;
14
15use crate::decision::{PolicyDecision, PolicyOutcome};
16use crate::types::{ContextIn, DecideRequest};
17
18#[derive(Debug, Error)]
19pub enum EngineError {
20    #[error("policy parse failed: {0}")]
21    PolicyParse(String),
22    #[error("request build failed: {0}")]
23    RequestBuild(String),
24    #[error("context build failed: {0}")]
25    ContextBuild(String),
26    #[error("entity build failed: {0}")]
27    EntityBuild(String),
28}
29
30/// Cedar-based policy engine for Converge gate decisions.
31pub struct PolicyEngine {
32    policies: PolicySet,
33    auth: Authorizer,
34}
35
36impl PolicyEngine {
37    /// Create an engine from a Cedar policy source string.
38    ///
39    /// # Errors
40    ///
41    /// Returns `Err` if the Cedar policy text fails to parse.
42    pub fn from_policy_str(policy_text: &str) -> Result<Self, EngineError> {
43        let ps: PolicySet = policy_text
44            .parse()
45            .map_err(|err| EngineError::PolicyParse(format!("{err:?}")))?;
46        Ok(Self {
47            policies: ps,
48            auth: Authorizer::new(),
49        })
50    }
51
52    /// Evaluate a policy decision.
53    ///
54    /// Builds Cedar principal (`Agent::Persona`), resource (`Flow::Commitment`),
55    /// and context from the request, then evaluates the loaded policies.
56    ///
57    /// # Errors
58    ///
59    /// Returns `Err` if entity or context construction fails.
60    pub fn evaluate(&self, req: &DecideRequest) -> Result<PolicyDecision, EngineError> {
61        let ctx = req.context.clone().unwrap_or_default();
62
63        // Build principal entity: Agent::Persona
64        let p_type = EntityTypeName::from_str("Agent::Persona")
65            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
66        let p_id = EntityId::from_str(&req.principal.id)
67            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
68        let p_uid = EntityUid::from_type_name_and_id(p_type, p_id);
69
70        let p_attrs: HashMap<String, RestrictedExpression> = HashMap::from([
71            (
72                "authority".to_string(),
73                RestrictedExpression::new_string(req.principal.authority.clone()),
74            ),
75            (
76                "policy_version".to_string(),
77                RestrictedExpression::new_string(
78                    req.principal.policy_version.clone().unwrap_or_default(),
79                ),
80            ),
81        ]);
82        let principal_entity = Entity::new(p_uid.clone(), p_attrs, HashSet::new());
83
84        // Build resource entity: Flow::Commitment
85        let r_type = EntityTypeName::from_str("Flow::Commitment")
86            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
87        let r_id = EntityId::from_str(&req.resource.id)
88            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
89        let r_uid = EntityUid::from_type_name_and_id(r_type, r_id);
90
91        let r_attrs: HashMap<String, RestrictedExpression> = HashMap::from([
92            (
93                "resource_type".to_string(),
94                RestrictedExpression::new_string(
95                    req.resource.resource_type.clone().unwrap_or_default(),
96                ),
97            ),
98            (
99                "phase".to_string(),
100                RestrictedExpression::new_string(req.resource.phase.clone().unwrap_or_default()),
101            ),
102        ]);
103        let resource_entity = Entity::new(r_uid.clone(), r_attrs, HashSet::new());
104
105        // Build entities set
106        let entities = Entities::from_entities([principal_entity, resource_entity])
107            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
108
109        // Build context as JSON — all decision-relevant facts
110        let ctx_json = serde_json::json!({
111            "commitment_type": ctx.commitment_type.clone().unwrap_or_default(),
112            "amount": ctx.amount.unwrap_or(0),
113            "human_approval_present": ctx.human_approval_present.unwrap_or(false),
114            "required_gates_met": ctx.required_gates_met.unwrap_or(false),
115        });
116        let context = Context::from_json_value(ctx_json, None)
117            .map_err(|e| EngineError::ContextBuild(e.to_string()))?;
118
119        // Build action UID
120        let action_uid: EntityUid = format!("Action::\"{}\"", req.action)
121            .parse()
122            .map_err(|e: cedar_policy::ParseErrors| EngineError::RequestBuild(e.to_string()))?;
123
124        let request = Request::new(Some(p_uid), Some(action_uid), Some(r_uid), context);
125
126        let response = self.auth.is_authorized(&request, &self.policies, &entities);
127        let cedar_decision = response.decision();
128
129        let outcome = match cedar_decision {
130            cedar_policy::Decision::Allow => PolicyOutcome::Promote,
131            cedar_policy::Decision::Deny => {
132                if should_escalate(&req.action, &req.principal.authority, &ctx) {
133                    PolicyOutcome::Escalate
134                } else {
135                    PolicyOutcome::Reject
136                }
137            }
138        };
139
140        let reasons: Vec<String> = response
141            .diagnostics()
142            .reason()
143            .map(std::string::ToString::to_string)
144            .collect();
145        let reason = if reasons.is_empty() {
146            None
147        } else {
148            Some(reasons.join(", "))
149        };
150
151        Ok(PolicyDecision::policy(
152            outcome,
153            reason,
154            req.principal.id.clone(),
155            req.action.clone(),
156            req.resource.id.clone(),
157        ))
158    }
159}
160
161/// Determine if a denied action should escalate rather than reject.
162///
163/// Escalation happens when:
164/// - The action is a commitment-level action (commit, promote)
165/// - The principal has authority that could be unlocked with human approval
166/// - Human approval is not yet present
167fn should_escalate(action: &str, authority: &str, ctx: &ContextIn) -> bool {
168    let commitment_actions = ["commit", "promote"];
169    let escalatable_authorities = ["supervisory", "participatory"];
170
171    commitment_actions.contains(&action)
172        && escalatable_authorities.contains(&authority)
173        && !ctx.human_approval_present.unwrap_or(false)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::types::{PrincipalIn, ResourceIn};
180
181    fn test_engine() -> PolicyEngine {
182        let policy = std::fs::read_to_string("policies/policy.cedar")
183            .expect("policy file should exist in test working dir");
184        PolicyEngine::from_policy_str(&policy).expect("policy should parse")
185    }
186
187    fn make_request(
188        authority: &str,
189        action: &str,
190        amount: i64,
191        human_approval: bool,
192    ) -> DecideRequest {
193        DecideRequest {
194            principal: PrincipalIn {
195                id: "agent:test".into(),
196                authority: authority.into(),
197                domains: vec!["test".into()],
198                policy_version: None,
199            },
200            resource: ResourceIn {
201                id: "flow:test-001".into(),
202                resource_type: Some("quote".into()),
203                phase: Some("convergence".into()),
204                gates_passed: Some(vec!["evidence".into()]),
205            },
206            action: action.into(),
207            context: Some(ContextIn {
208                commitment_type: Some("quote".into()),
209                amount: Some(amount),
210                human_approval_present: Some(human_approval),
211                required_gates_met: Some(true),
212            }),
213            observe: None,
214            delegation_b64: None,
215        }
216    }
217
218    #[test]
219    fn advisory_can_propose() {
220        let engine = test_engine();
221        let req = make_request("advisory", "propose", 5000, false);
222        let decision = engine.evaluate(&req).unwrap();
223        assert_eq!(decision.outcome, PolicyOutcome::Promote);
224    }
225
226    #[test]
227    fn advisory_cannot_commit() {
228        let engine = test_engine();
229        let req = make_request("advisory", "commit", 5000, false);
230        let decision = engine.evaluate(&req).unwrap();
231        assert_ne!(decision.outcome, PolicyOutcome::Promote);
232    }
233
234    #[test]
235    fn supervisory_can_commit_with_approval() {
236        let engine = test_engine();
237        let req = make_request("supervisory", "commit", 25000, true);
238        let decision = engine.evaluate(&req).unwrap();
239        assert_eq!(decision.outcome, PolicyOutcome::Promote);
240    }
241
242    #[test]
243    fn supervisory_escalates_without_approval() {
244        let engine = test_engine();
245        let req = make_request("supervisory", "commit", 25000, false);
246        let decision = engine.evaluate(&req).unwrap();
247        assert_eq!(decision.outcome, PolicyOutcome::Escalate);
248    }
249
250    #[test]
251    fn sovereign_can_commit_autonomously() {
252        let engine = test_engine();
253        let req = make_request("sovereign", "commit", 25000, false);
254        let decision = engine.evaluate(&req).unwrap();
255        assert_eq!(decision.outcome, PolicyOutcome::Promote);
256    }
257}