use std::str::FromStr;
use cedar_policy::{
Authorizer, Context, Entities, EntityUid, PolicySet, Request, Schema, ValidationMode, Validator,
};
use crate::core::{
Digest, PolicyBundleIdentity, PolicyDecision, PolicyEngine, PolicyRequest, canon,
};
use crate::runtime::telemetry;
pub const EVALUATOR_SEMANTICS: &str =
"cedar-policy/4.12.0;agentplane-adapter/2;extensions=all-available";
const ADAPTER_CONFIGURATION: &[u8] =
b"principal=Agent;action=Action;resource=Resource;context=action-schema";
#[derive(Debug)]
pub struct CedarEngine {
policies: PolicySet,
entities: Entities,
schema: Option<Schema>,
bundle: PolicyBundleIdentity,
}
#[derive(Debug, thiserror::Error)]
pub enum CedarError {
#[error("policy set does not parse: {0}")]
Parse(String),
#[error("Cedar schema does not parse: {0}")]
Schema(String),
#[error("policy set does not validate against its schema: {0}")]
Validation(String),
#[error("static Cedar entities do not parse against the bundle schema: {0}")]
Entities(String),
}
impl CedarEngine {
pub fn new(source: &str) -> Result<Self, CedarError> {
Self::from_bundle(source, None, None)
}
pub fn from_bundle(
source: &str,
schema_json: Option<&str>,
entities_json: Option<&str>,
) -> Result<Self, CedarError> {
let policies = PolicySet::from_str(source).map_err(|e| CedarError::Parse(e.to_string()))?;
let (schema, schema_digest) = match schema_json {
Some(json) => {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| CedarError::Schema(e.to_string()))?;
let schema = Schema::from_json_value(value.clone())
.map_err(|e| CedarError::Schema(e.to_string()))?;
let validation =
Validator::new(schema.clone()).validate(&policies, ValidationMode::Strict);
if !validation.validation_passed() {
let errors = validation
.validation_errors()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
return Err(CedarError::Validation(errors));
}
(Some(schema), Some(Digest::of(&canon::value_bytes(&value))))
}
None => (None, None),
};
let (entities, entities_digest) = match entities_json {
Some(json) => {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| CedarError::Entities(e.to_string()))?;
let entities = Entities::from_json_value(value.clone(), schema.as_ref())
.map_err(|e| CedarError::Entities(e.to_string()))?;
(entities, Some(Digest::of(&canon::value_bytes(&value))))
}
None => (Entities::empty(), None),
};
let mut bundle =
PolicyBundleIdentity::new(Digest::of(source.as_bytes()), EVALUATOR_SEMANTICS)
.with_configuration(Digest::of(ADAPTER_CONFIGURATION));
if let Some(digest) = schema_digest {
bundle = bundle.with_schema(digest);
}
if let Some(digest) = entities_digest {
bundle = bundle.with_entities(digest);
}
Ok(Self {
policies,
entities,
schema,
bundle,
})
}
fn request(&self, r: &PolicyRequest<'_>) -> Result<Request, String> {
let principal = uid("Agent", r.principal)?;
let action = uid("Action", r.action)?;
let resource = uid("Resource", r.resource)?;
let context = Context::from_json_value(
r.context.clone(),
self.schema.as_ref().map(|schema| (schema, &action)),
)
.map_err(|e| format!("context is not a Cedar record: {e}"))?;
Request::new(principal, action, resource, context, self.schema.as_ref())
.map_err(|e| format!("request is not well formed: {e}"))
}
}
fn uid(kind: &str, id: &str) -> Result<EntityUid, String> {
let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
EntityUid::from_str(&format!("{kind}::\"{escaped}\""))
.map_err(|e| format!("'{id}' is not a usable Cedar entity id: {e}"))
}
impl PolicyEngine for CedarEngine {
fn authorize(&self, request: &PolicyRequest<'_>) -> PolicyDecision {
let req = match self.request(request) {
Ok(r) => r,
Err(why) => {
tracing::error!(
target: telemetry::POLICY_DENIED,
action = %request.action,
resource = %request.resource,
malformed = true,
%why,
);
return PolicyDecision::deny(format!(
"the authorization request could not be expressed for evaluation \
({why}) — this is a defect, not a rule: every request of this \
shape is being denied"
));
}
};
let answer = Authorizer::new().is_authorized(&req, &self.policies, &self.entities);
let errors: Vec<String> = answer
.diagnostics()
.errors()
.map(ToString::to_string)
.collect();
if !errors.is_empty() {
tracing::error!(
target: telemetry::POLICY_DENIED,
action = %request.action,
resource = %request.resource,
policy_error = true,
detail = %errors.join("; "),
);
}
match answer.decision() {
cedar_policy::Decision::Allow if errors.is_empty() => PolicyDecision::Permit,
cedar_policy::Decision::Allow => PolicyDecision::Permit,
cedar_policy::Decision::Deny if !errors.is_empty() => PolicyDecision::deny(format!(
"denied while {} policy error(s) went unevaluated: {} — fix the \
policy set; this denial may not mean what it appears to",
errors.len(),
errors.join("; ")
)),
cedar_policy::Decision::Deny => {
let determining: Vec<String> = answer
.diagnostics()
.reason()
.map(ToString::to_string)
.collect();
if determining.is_empty() {
PolicyDecision::deny(format!(
"no policy permits '{}' on '{}'",
request.action, request.resource
))
} else {
PolicyDecision::deny(format!(
"'{}' on '{}' refused by {}",
request.action,
request.resource,
determining.join(", ")
))
}
}
}
}
fn bundle(&self) -> PolicyBundleIdentity {
self.bundle.clone()
}
}