Skip to main content

agentstategraph_policy_cedar/
lib.rs

1//! Cedar runner for AgentStateGraph policies (0.7.5 §4b).
2//!
3//! [`CedarEvaluator`] shells out to the `cedar` binary on `$PATH` (or
4//! an explicit override via [`CedarEvaluator::new_with_path`]). The
5//! Cedar source is written to a temp file, an `entities.json` and a
6//! `request.json` are synthesized per-call, and the decision is pulled
7//! from the `decision` field of `cedar authorize`'s JSON output.
8//!
9//! # Entity / request model
10//!
11//! A single `Agent` entity is synthesized per call:
12//!
13//! ```json
14//! { "uid": {"type": "Agent", "id": "<agent_id>"}, "attrs": {}, "parents": [] }
15//! ```
16//!
17//! The request is:
18//!
19//! - `principal` = `Agent::"<agent_id>"`
20//! - `action`    = `Action::"<action>"`
21//! - `resource`  = `Situation::"default"`
22//! - `context`   = `{ "situation": <situation-map> }`
23//!
24//! Invocation:
25//!
26//! ```text
27//! cedar authorize \
28//!   --policies  <policies.cedar> \
29//!   --entities  <entities.json> \
30//!   --request-json <request.json>
31//! ```
32//!
33//! The decision is read from the `decision` field of the JSON output
34//! and mapped: `Allow` → [`Decision::Allow`], `Deny` →
35//! [`Decision::Deny`], everything else → [`Decision::NoPolicyMatch`].
36//! `matched_policy` is the first entry in `determining_policies`, or
37//! the literal `"cedar"` if absent.
38//!
39//! # Source variants
40//!
41//! - [`EvaluatorSource::Inline`] — body is Cedar policy source text.
42//! - [`EvaluatorSource::FilePath`] — Cedar source path is passed
43//!   through to `cedar authorize --policies`.
44//! - [`EvaluatorSource::CommitRef`] — unsupported here; resolve via
45//!   [`PolicyStore`] before dispatch. Returns
46//!   [`ExternalError::SourceResolution`].
47
48use 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
58/// Shells out to `cedar authorize`.
59pub struct CedarEvaluator {
60    /// Path (or bare name) of the `cedar` binary. Defaults to
61    /// `"cedar"` (resolved against `$PATH`).
62    cedar_path: String,
63    /// How long `cedar authorize` may run before it's killed.
64    timeout: Duration,
65}
66
67impl CedarEvaluator {
68    /// Construct with the default `"cedar"` binary (resolved on `$PATH`).
69    pub fn new() -> Self {
70        Self::new_with_path("cedar")
71    }
72
73    /// Override the Cedar binary path. Useful for tests and for
74    /// servers that ship a vendored `cedar` next to their own binary.
75    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    /// How long `cedar authorize` may run before it's killed and the
83    /// evaluation fails with [`agentstategraph_policy::external::TimedOut`].
84    /// [`DEFAULT_TIMEOUT`] unless set.
85    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        // 1. Resolve the policies-file path. For Inline we stage to a
110        //    temp file; for FilePath we pass through unchanged; for
111        //    CommitRef we bail out — the PolicyStore is responsible
112        //    for materialising those.
113        //
114        //    The tempfile is bound to this scope so it survives until
115        //    `cedar authorize` has finished reading it.
116        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; // silence unused binding warning on the FilePath arm
142
143        // 2. entities.json — single synthesized Agent entity.
144        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        // 3. request.json.
163        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        // 4. Invoke `cedar authorize`.
181        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        // 5. Parse JSON output.
214        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    /// Skip the enclosing test when the `cedar` binary is not on
258    /// `$PATH`. Mirrors the Rego runner's `requires_opa!()`.
259    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        // No cedar binary needed: CommitRef must short-circuit before
312        // we even try to spawn.
313        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}