1use 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
30pub struct PolicyEngine {
32 policies: PolicySet,
33 auth: Authorizer,
34}
35
36impl PolicyEngine {
37 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 pub fn evaluate(&self, req: &DecideRequest) -> Result<PolicyDecision, EngineError> {
61 let ctx = req.context.clone().unwrap_or_default();
62
63 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 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 let entities = Entities::from_entities([principal_entity, resource_entity])
107 .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
108
109 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 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
161fn 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}