agentstategraph_policy_cedar/
lib.rs1use std::io::ErrorKind;
49use std::process::{Command, Stdio};
50use std::time::Duration;
51
52use agentstategraph_policy::external::{
53 DEFAULT_TIMEOUT, ExternalError, ExternalEvaluator, wait_with_timeout,
54};
55use agentstategraph_policy::selector::Situation;
56use agentstategraph_policy::types::{Decision, EvaluatorSource};
57
58pub struct CedarEvaluator {
60 cedar_path: String,
63 timeout: Duration,
65}
66
67impl CedarEvaluator {
68 pub fn new() -> Self {
70 Self::new_with_path("cedar")
71 }
72
73 pub fn new_with_path(cedar_path: impl Into<String>) -> Self {
76 Self {
77 cedar_path: cedar_path.into(),
78 timeout: DEFAULT_TIMEOUT,
79 }
80 }
81
82 pub fn with_timeout(mut self, timeout: Duration) -> Self {
86 self.timeout = timeout;
87 self
88 }
89}
90
91impl Default for CedarEvaluator {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl ExternalEvaluator for CedarEvaluator {
98 fn kind(&self) -> &'static str {
99 "cedar"
100 }
101
102 fn evaluate(
103 &self,
104 source: &EvaluatorSource,
105 situation: &Situation,
106 action: &str,
107 agent_id: &str,
108 ) -> Result<Decision, ExternalError> {
109 let policies_tmp;
117 let policies_path: std::path::PathBuf = match source {
118 EvaluatorSource::Inline { body } => {
119 let tmp = tempfile::Builder::new()
120 .suffix(".cedar")
121 .tempfile()
122 .map_err(|e| ExternalError::Execution(format!("tempfile (policies): {e}")))?;
123 std::fs::write(tmp.path(), body.as_bytes())
124 .map_err(|e| ExternalError::Execution(format!("write cedar policies: {e}")))?;
125 let p = tmp.path().to_path_buf();
126 policies_tmp = Some(tmp);
127 p
128 }
129 EvaluatorSource::FilePath { path } => {
130 policies_tmp = None;
131 path.clone()
132 }
133 EvaluatorSource::CommitRef { .. } => {
134 return Err(ExternalError::SourceResolution(
135 "commit_ref source not resolvable by the cedar runner; resolve via \
136 PolicyStore before dispatch"
137 .into(),
138 ));
139 }
140 };
141 let _ = &policies_tmp; let entities = serde_json::json!([
145 {
146 "uid": { "type": "Agent", "id": agent_id },
147 "attrs": {},
148 "parents": []
149 }
150 ]);
151 let entities_tmp = tempfile::Builder::new()
152 .suffix(".entities.json")
153 .tempfile()
154 .map_err(|e| ExternalError::Execution(format!("tempfile (entities): {e}")))?;
155 std::fs::write(
156 entities_tmp.path(),
157 serde_json::to_vec(&entities)
158 .map_err(|e| ExternalError::Execution(format!("serialize entities: {e}")))?,
159 )
160 .map_err(|e| ExternalError::Execution(format!("write entities.json: {e}")))?;
161
162 let request = serde_json::json!({
164 "principal": format!("Agent::\"{agent_id}\""),
165 "action": format!("Action::\"{action}\""),
166 "resource": "Situation::\"default\"",
167 "context": { "situation": &situation.0 },
168 });
169 let request_tmp = tempfile::Builder::new()
170 .suffix(".request.json")
171 .tempfile()
172 .map_err(|e| ExternalError::Execution(format!("tempfile (request): {e}")))?;
173 std::fs::write(
174 request_tmp.path(),
175 serde_json::to_vec(&request)
176 .map_err(|e| ExternalError::Execution(format!("serialize request: {e}")))?,
177 )
178 .map_err(|e| ExternalError::Execution(format!("write request.json: {e}")))?;
179
180 let child = Command::new(&self.cedar_path)
182 .arg("authorize")
183 .arg("--policies")
184 .arg(&policies_path)
185 .arg("--entities")
186 .arg(entities_tmp.path())
187 .arg("--request-json")
188 .arg(request_tmp.path())
189 .stdin(Stdio::null())
190 .stdout(Stdio::piped())
191 .stderr(Stdio::piped())
192 .spawn()
193 .map_err(|e| {
194 if e.kind() == ErrorKind::NotFound {
195 ExternalError::Execution(format!(
196 "cedar binary not found at {}: {e}",
197 self.cedar_path
198 ))
199 } else {
200 ExternalError::Execution(format!("failed to invoke {}: {e}", self.cedar_path))
201 }
202 })?;
203 let output = wait_with_timeout(child, None, self.timeout)?;
204
205 if !output.status.success() {
206 return Err(ExternalError::Execution(format!(
207 "cedar authorize exited {}: {}",
208 output.status,
209 String::from_utf8_lossy(&output.stderr)
210 )));
211 }
212
213 let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
215 ExternalError::Execution(format!(
216 "parse cedar authorize output: {e} (stdout={})",
217 String::from_utf8_lossy(&output.stdout)
218 ))
219 })?;
220
221 let decision_str = envelope
222 .get("decision")
223 .and_then(|v| v.as_str())
224 .unwrap_or("");
225
226 let matched_policy: String = envelope
227 .get("determining_policies")
228 .and_then(|v| v.as_array())
229 .and_then(|arr| arr.first())
230 .and_then(|v| {
231 v.as_str()
232 .map(str::to_owned)
233 .or_else(|| Some(v.to_string()))
234 })
235 .unwrap_or_else(|| "cedar".into());
236
237 Ok(match decision_str {
238 "Allow" => Decision::Allow {
239 matched_policy,
240 preconditions: vec![],
241 },
242 "Deny" => Decision::Deny {
243 matched_policy,
244 reason: "cedar authorize returned Deny".into(),
245 },
246 _ => Decision::NoPolicyMatch,
247 })
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use agentstategraph_policy::selector::Situation;
255 use std::collections::HashMap;
256
257 macro_rules! requires_cedar {
260 () => {
261 if Command::new("cedar").arg("--help").output().is_err() {
262 eprintln!("skipping: cedar binary not on $PATH");
263 return;
264 }
265 };
266 }
267
268 fn empty_situation() -> Situation {
269 Situation(HashMap::new())
270 }
271
272 #[test]
273 fn inline_allow_happy_path() {
274 requires_cedar!();
275 let ev = CedarEvaluator::new();
276 let src = EvaluatorSource::Inline {
277 body: "permit(principal, action, resource);".into(),
278 };
279 let decision = ev
280 .evaluate(&src, &empty_situation(), "read", "alice")
281 .expect("cedar inline evaluation");
282 match decision {
283 Decision::Allow { .. } => {}
284 other => panic!("expected Allow, got {other:?}"),
285 }
286 }
287
288 #[test]
289 fn file_path_source() {
290 requires_cedar!();
291 let tmp = tempfile::Builder::new()
292 .suffix(".cedar")
293 .tempfile()
294 .expect("tempfile");
295 std::fs::write(tmp.path(), b"permit(principal, action, resource);").expect("write policy");
296 let ev = CedarEvaluator::new();
297 let src = EvaluatorSource::FilePath {
298 path: tmp.path().to_path_buf(),
299 };
300 let decision = ev
301 .evaluate(&src, &empty_situation(), "read", "alice")
302 .expect("cedar file evaluation");
303 match decision {
304 Decision::Allow { .. } => {}
305 other => panic!("expected Allow, got {other:?}"),
306 }
307 }
308
309 #[test]
310 fn commit_ref_rejected_with_source_resolution() {
311 let ev = CedarEvaluator::new();
314 let src = EvaluatorSource::CommitRef {
315 path: "policy.cedar".into(),
316 };
317 let err = ev
318 .evaluate(&src, &empty_situation(), "read", "alice")
319 .expect_err("commit_ref must error");
320 match err {
321 ExternalError::SourceResolution(msg) => {
322 assert!(
323 msg.contains("commit_ref"),
324 "expected commit_ref message, got: {msg}"
325 );
326 }
327 other => panic!("expected SourceResolution, got {other:?}"),
328 }
329 }
330
331 #[test]
332 fn missing_binary_returns_execution_error() {
333 let ev = CedarEvaluator::new_with_path("/definitely/not/a/real/path/to/cedar-binary-xyzzy");
334 let src = EvaluatorSource::Inline {
335 body: "permit(principal, action, resource);".into(),
336 };
337 let err = ev
338 .evaluate(&src, &empty_situation(), "read", "alice")
339 .expect_err("missing binary must error");
340 match err {
341 ExternalError::Execution(msg) => {
342 assert!(
343 msg.contains("cedar binary not found"),
344 "expected 'cedar binary not found' in message, got: {msg}"
345 );
346 }
347 other => panic!("expected Execution, got {other:?}"),
348 }
349 }
350}