use std::io::ErrorKind;
use std::process::{Command, Stdio};
use std::time::Duration;
use agentstategraph_policy::external::{
DEFAULT_TIMEOUT, ExternalError, ExternalEvaluator, wait_with_timeout,
};
use agentstategraph_policy::selector::Situation;
use agentstategraph_policy::types::{Decision, EvaluatorSource};
pub struct CedarEvaluator {
cedar_path: String,
timeout: Duration,
}
impl CedarEvaluator {
pub fn new() -> Self {
Self::new_with_path("cedar")
}
pub fn new_with_path(cedar_path: impl Into<String>) -> Self {
Self {
cedar_path: cedar_path.into(),
timeout: DEFAULT_TIMEOUT,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
}
impl Default for CedarEvaluator {
fn default() -> Self {
Self::new()
}
}
impl ExternalEvaluator for CedarEvaluator {
fn kind(&self) -> &'static str {
"cedar"
}
fn evaluate(
&self,
source: &EvaluatorSource,
situation: &Situation,
action: &str,
agent_id: &str,
) -> Result<Decision, ExternalError> {
let policies_tmp;
let policies_path: std::path::PathBuf = match source {
EvaluatorSource::Inline { body } => {
let tmp = tempfile::Builder::new()
.suffix(".cedar")
.tempfile()
.map_err(|e| ExternalError::Execution(format!("tempfile (policies): {e}")))?;
std::fs::write(tmp.path(), body.as_bytes())
.map_err(|e| ExternalError::Execution(format!("write cedar policies: {e}")))?;
let p = tmp.path().to_path_buf();
policies_tmp = Some(tmp);
p
}
EvaluatorSource::FilePath { path } => {
policies_tmp = None;
path.clone()
}
EvaluatorSource::CommitRef { .. } => {
return Err(ExternalError::SourceResolution(
"commit_ref source not resolvable by the cedar runner; resolve via \
PolicyStore before dispatch"
.into(),
));
}
};
let _ = &policies_tmp;
let entities = serde_json::json!([
{
"uid": { "type": "Agent", "id": agent_id },
"attrs": {},
"parents": []
}
]);
let entities_tmp = tempfile::Builder::new()
.suffix(".entities.json")
.tempfile()
.map_err(|e| ExternalError::Execution(format!("tempfile (entities): {e}")))?;
std::fs::write(
entities_tmp.path(),
serde_json::to_vec(&entities)
.map_err(|e| ExternalError::Execution(format!("serialize entities: {e}")))?,
)
.map_err(|e| ExternalError::Execution(format!("write entities.json: {e}")))?;
let request = serde_json::json!({
"principal": format!("Agent::\"{agent_id}\""),
"action": format!("Action::\"{action}\""),
"resource": "Situation::\"default\"",
"context": { "situation": &situation.0 },
});
let request_tmp = tempfile::Builder::new()
.suffix(".request.json")
.tempfile()
.map_err(|e| ExternalError::Execution(format!("tempfile (request): {e}")))?;
std::fs::write(
request_tmp.path(),
serde_json::to_vec(&request)
.map_err(|e| ExternalError::Execution(format!("serialize request: {e}")))?,
)
.map_err(|e| ExternalError::Execution(format!("write request.json: {e}")))?;
let child = Command::new(&self.cedar_path)
.arg("authorize")
.arg("--policies")
.arg(&policies_path)
.arg("--entities")
.arg(entities_tmp.path())
.arg("--request-json")
.arg(request_tmp.path())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
if e.kind() == ErrorKind::NotFound {
ExternalError::Execution(format!(
"cedar binary not found at {}: {e}",
self.cedar_path
))
} else {
ExternalError::Execution(format!("failed to invoke {}: {e}", self.cedar_path))
}
})?;
let output = wait_with_timeout(child, None, self.timeout)?;
if !output.status.success() {
return Err(ExternalError::Execution(format!(
"cedar authorize exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
)));
}
let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
ExternalError::Execution(format!(
"parse cedar authorize output: {e} (stdout={})",
String::from_utf8_lossy(&output.stdout)
))
})?;
let decision_str = envelope
.get("decision")
.and_then(|v| v.as_str())
.unwrap_or("");
let matched_policy: String = envelope
.get("determining_policies")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| {
v.as_str()
.map(str::to_owned)
.or_else(|| Some(v.to_string()))
})
.unwrap_or_else(|| "cedar".into());
Ok(match decision_str {
"Allow" => Decision::Allow {
matched_policy,
preconditions: vec![],
},
"Deny" => Decision::Deny {
matched_policy,
reason: "cedar authorize returned Deny".into(),
},
_ => Decision::NoPolicyMatch,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use agentstategraph_policy::selector::Situation;
use std::collections::HashMap;
macro_rules! requires_cedar {
() => {
if Command::new("cedar").arg("--help").output().is_err() {
eprintln!("skipping: cedar binary not on $PATH");
return;
}
};
}
fn empty_situation() -> Situation {
Situation(HashMap::new())
}
#[test]
fn inline_allow_happy_path() {
requires_cedar!();
let ev = CedarEvaluator::new();
let src = EvaluatorSource::Inline {
body: "permit(principal, action, resource);".into(),
};
let decision = ev
.evaluate(&src, &empty_situation(), "read", "alice")
.expect("cedar inline evaluation");
match decision {
Decision::Allow { .. } => {}
other => panic!("expected Allow, got {other:?}"),
}
}
#[test]
fn file_path_source() {
requires_cedar!();
let tmp = tempfile::Builder::new()
.suffix(".cedar")
.tempfile()
.expect("tempfile");
std::fs::write(tmp.path(), b"permit(principal, action, resource);").expect("write policy");
let ev = CedarEvaluator::new();
let src = EvaluatorSource::FilePath {
path: tmp.path().to_path_buf(),
};
let decision = ev
.evaluate(&src, &empty_situation(), "read", "alice")
.expect("cedar file evaluation");
match decision {
Decision::Allow { .. } => {}
other => panic!("expected Allow, got {other:?}"),
}
}
#[test]
fn commit_ref_rejected_with_source_resolution() {
let ev = CedarEvaluator::new();
let src = EvaluatorSource::CommitRef {
path: "policy.cedar".into(),
};
let err = ev
.evaluate(&src, &empty_situation(), "read", "alice")
.expect_err("commit_ref must error");
match err {
ExternalError::SourceResolution(msg) => {
assert!(
msg.contains("commit_ref"),
"expected commit_ref message, got: {msg}"
);
}
other => panic!("expected SourceResolution, got {other:?}"),
}
}
#[test]
fn missing_binary_returns_execution_error() {
let ev = CedarEvaluator::new_with_path("/definitely/not/a/real/path/to/cedar-binary-xyzzy");
let src = EvaluatorSource::Inline {
body: "permit(principal, action, resource);".into(),
};
let err = ev
.evaluate(&src, &empty_situation(), "read", "alice")
.expect_err("missing binary must error");
match err {
ExternalError::Execution(msg) => {
assert!(
msg.contains("cedar binary not found"),
"expected 'cedar binary not found' in message, got: {msg}"
);
}
other => panic!("expected Execution, got {other:?}"),
}
}
}