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};
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
31pub struct PolicyEngine {
33 policies: PolicySet,
34 auth: Authorizer,
35}
36
37impl PolicyEngine {
38 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 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 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 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 let entities = Entities::from_entities([principal_entity, resource_entity], None)
157 .map_err(|e| EngineError::EntityBuild(e.to_string()))?;
158
159 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 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}