Skip to main content

arbiter/
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};
17use converge_pack::{DomainId, GateId, PolicyVersionId, ResourceKind};
18
19#[derive(Debug, Error)]
20pub enum EngineError {
21    #[error("policy parse failed: {0}")]
22    PolicyParse(String),
23    #[error("request build failed: {0}")]
24    RequestBuild(String),
25    #[error("context build failed: {0}")]
26    ContextBuild(String),
27    #[error("entity build failed: {0}")]
28    EntityBuild(String),
29}
30
31/// Cedar-based policy engine for Converge gate decisions.
32pub struct PolicyEngine {
33    policies: PolicySet,
34    auth: Authorizer,
35}
36
37impl PolicyEngine {
38    /// Create an engine from a Cedar policy source string.
39    ///
40    /// # Errors
41    ///
42    /// Returns `Err` if the Cedar policy text fails to parse.
43    pub fn from_policy_str(policy_text: &str) -> Result<Self, EngineError> {
44        let ps: PolicySet = policy_text
45            .parse()
46            .map_err(|err| EngineError::PolicyParse(format!("{err:?}")))?;
47        Ok(Self {
48            policies: ps,
49            auth: Authorizer::new(),
50        })
51    }
52
53    /// Evaluate a policy decision.
54    ///
55    /// Builds Cedar principal (`Suggestor::Persona`), resource (`Flow::Commitment`),
56    /// and context from the request, then evaluates the loaded policies.
57    ///
58    /// # Errors
59    ///
60    /// Returns `Err` if entity or context construction fails.
61    pub fn evaluate(&self, req: &DecideRequest) -> Result<PolicyDecision, EngineError> {
62        let evaluation = self.evaluate_cedar(req)?;
63
64        let outcome = match evaluation.decision {
65            cedar_policy::Decision::Allow => PolicyOutcome::Promote,
66            cedar_policy::Decision::Deny => {
67                if self.can_escalate_with_human_approval(req)? {
68                    PolicyOutcome::Escalate
69                } else {
70                    PolicyOutcome::Reject
71                }
72            }
73        };
74
75        Ok(PolicyDecision::policy(
76            outcome,
77            evaluation.reason,
78            req.principal.id.clone(),
79            req.action,
80            req.resource.id.clone(),
81        ))
82    }
83
84    fn evaluate_cedar(&self, req: &DecideRequest) -> Result<CedarEvaluation, EngineError> {
85        let ctx = req.context.clone().unwrap_or_default();
86
87        // Build principal entity: Suggestor::Persona
88        let p_type = EntityTypeName::from_str("Suggestor::Persona")
89            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
90        let p_id = EntityId::from_str(&req.principal.id)
91            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
92        let p_uid = EntityUid::from_type_name_and_id(p_type, p_id);
93
94        let p_attrs: HashMap<String, RestrictedExpression> = HashMap::from([
95            (
96                "authority".to_string(),
97                RestrictedExpression::new_string(req.principal.authority.as_str().to_string()),
98            ),
99            (
100                "policy_version".to_string(),
101                RestrictedExpression::new_string(
102                    req.principal
103                        .policy_version
104                        .as_ref()
105                        .map_or_else(String::new, PolicyVersionId::to_string),
106                ),
107            ),
108            (
109                "domains".to_string(),
110                string_set(req.principal.domains.iter().map(DomainId::to_string)),
111            ),
112        ]);
113        let principal_entity = Entity::new(p_uid.clone(), p_attrs, HashSet::new())
114            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
115
116        // Build resource entity: Flow::Commitment
117        let r_type = EntityTypeName::from_str("Flow::Commitment")
118            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
119        let r_id = EntityId::from_str(&req.resource.id)
120            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
121        let r_uid = EntityUid::from_type_name_and_id(r_type, r_id);
122
123        let r_attrs: HashMap<String, RestrictedExpression> = HashMap::from([
124            (
125                "resource_type".to_string(),
126                RestrictedExpression::new_string(
127                    req.resource
128                        .resource_type
129                        .as_ref()
130                        .map_or_else(String::new, ResourceKind::to_string),
131                ),
132            ),
133            (
134                "phase".to_string(),
135                RestrictedExpression::new_string(
136                    req.resource
137                        .phase
138                        .map_or_else(String::new, |phase| phase.as_str().to_string()),
139                ),
140            ),
141            (
142                "gates_passed".to_string(),
143                string_set(
144                    req.resource
145                        .gates_passed
146                        .iter()
147                        .flatten()
148                        .map(GateId::to_string),
149                ),
150            ),
151        ]);
152        let resource_entity = Entity::new(r_uid.clone(), r_attrs, HashSet::new())
153            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
154
155        // Build entities set
156        let entities = Entities::from_entities([principal_entity, resource_entity], None)
157            .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
158
159        // Build context as JSON — all decision-relevant facts
160        let ctx_json = serde_json::json!({
161            "commitment_type": ctx.commitment_type.clone().unwrap_or_default(),
162            "amount": ctx.amount.unwrap_or(0),
163            "human_approval_present": ctx.human_approval_present.unwrap_or(false),
164            "required_gates_met": ctx.required_gates_met.unwrap_or(false),
165            "principal_domains": req.principal.domains.iter().map(DomainId::as_str).collect::<Vec<_>>(),
166            "gates_passed": req.resource.gates_passed.iter().flatten().map(GateId::as_str).collect::<Vec<_>>(),
167        });
168        let context = Context::from_json_value(ctx_json, None)
169            .map_err(|e| EngineError::ContextBuild(e.to_string()))?;
170
171        // Build action UID
172        let action_uid: EntityUid = format!("Action::\"{}\"", req.action.as_str())
173            .parse()
174            .map_err(|e: cedar_policy::ParseErrors| EngineError::RequestBuild(e.to_string()))?;
175
176        let request = Request::new(p_uid, action_uid, r_uid, context, None)
177            .map_err(|e| EngineError::RequestBuild(e.to_string()))?;
178
179        let response = self.auth.is_authorized(&request, &self.policies, &entities);
180
181        let reasons: Vec<String> = response
182            .diagnostics()
183            .reason()
184            .map(std::string::ToString::to_string)
185            .collect();
186        let reason = if reasons.is_empty() {
187            None
188        } else {
189            Some(reasons.join(", "))
190        };
191
192        Ok(CedarEvaluation {
193            decision: response.decision(),
194            reason,
195        })
196    }
197
198    fn can_escalate_with_human_approval(&self, req: &DecideRequest) -> Result<bool, EngineError> {
199        if req
200            .context
201            .as_ref()
202            .and_then(|ctx| ctx.human_approval_present)
203            .unwrap_or(false)
204        {
205            return Ok(false);
206        }
207
208        let mut approval_req = req.clone();
209        approval_req
210            .context
211            .get_or_insert_with(ContextIn::default)
212            .human_approval_present = Some(true);
213
214        Ok(matches!(
215            self.evaluate_cedar(&approval_req)?.decision,
216            cedar_policy::Decision::Allow
217        ))
218    }
219}
220
221struct CedarEvaluation {
222    decision: cedar_policy::Decision,
223    reason: Option<String>,
224}
225
226fn string_set(values: impl IntoIterator<Item = String>) -> RestrictedExpression {
227    RestrictedExpression::new_set(values.into_iter().map(RestrictedExpression::new_string))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::types::{PrincipalIn, ResourceIn};
234    use converge_core::{AuthorityLevel, FlowAction, FlowPhase};
235    use converge_pack::{DomainId, GateId, PolicyVersionId, ResourceKind};
236
237    fn test_engine() -> PolicyEngine {
238        let policy = std::fs::read_to_string("policies/policy.cedar")
239            .expect("policy file should exist in test working dir");
240        PolicyEngine::from_policy_str(&policy).expect("policy should parse")
241    }
242
243    fn make_request(
244        authority: AuthorityLevel,
245        action: FlowAction,
246        amount: i64,
247        human_approval: bool,
248    ) -> DecideRequest {
249        DecideRequest {
250            principal: PrincipalIn {
251                id: "agent:test".into(),
252                authority,
253                domains: vec![DomainId::new("test")],
254                policy_version: None::<PolicyVersionId>,
255            },
256            resource: ResourceIn {
257                id: "flow:test-001".into(),
258                resource_type: Some(ResourceKind::new("quote")),
259                phase: Some(FlowPhase::Convergence),
260                gates_passed: Some(vec![GateId::new("evidence")]),
261            },
262            action,
263            context: Some(ContextIn {
264                commitment_type: Some("quote".into()),
265                amount: Some(amount),
266                human_approval_present: Some(human_approval),
267                required_gates_met: Some(true),
268            }),
269            delegation_b64: None,
270        }
271    }
272
273    #[test]
274    fn advisory_can_propose() {
275        let engine = test_engine();
276        let req = make_request(AuthorityLevel::Advisory, FlowAction::Propose, 5000, false);
277        let decision = engine.evaluate(&req).unwrap();
278        assert_eq!(decision.outcome, PolicyOutcome::Promote);
279    }
280
281    #[test]
282    fn advisory_cannot_commit() {
283        let engine = test_engine();
284        let req = make_request(AuthorityLevel::Advisory, FlowAction::Commit, 5000, false);
285        let decision = engine.evaluate(&req).unwrap();
286        assert_ne!(decision.outcome, PolicyOutcome::Promote);
287    }
288
289    #[test]
290    fn supervisory_can_commit_with_approval() {
291        let engine = test_engine();
292        let req = make_request(AuthorityLevel::Supervisory, FlowAction::Commit, 25000, true);
293        let decision = engine.evaluate(&req).unwrap();
294        assert_eq!(decision.outcome, PolicyOutcome::Promote);
295    }
296
297    #[test]
298    fn supervisory_escalates_without_approval() {
299        let engine = test_engine();
300        let req = make_request(
301            AuthorityLevel::Supervisory,
302            FlowAction::Commit,
303            25000,
304            false,
305        );
306        let decision = engine.evaluate(&req).unwrap();
307        assert_eq!(decision.outcome, PolicyOutcome::Escalate);
308    }
309
310    #[test]
311    fn sovereign_can_commit_autonomously() {
312        let engine = test_engine();
313        let req = make_request(AuthorityLevel::Sovereign, FlowAction::Commit, 25000, false);
314        let decision = engine.evaluate(&req).unwrap();
315        assert_eq!(decision.outcome, PolicyOutcome::Promote);
316    }
317}