use std::str::FromStr;
use cedar_policy::{
Authorizer, Context, Entities, EntityUid, PolicySet, Request, Schema, ValidationMode, Validator,
};
use serde_json::Value;
use crate::core::{
Digest, PolicyBundleIdentity, PolicyDecision, PolicyEngine, PolicyRequest, canon,
};
use crate::runtime::telemetry;
pub const CEDAR_LANGUAGE: &str = "4.5.0";
const ADAPTER_REVISION: u32 = 5;
#[must_use]
pub fn evaluator_semantics() -> String {
format!(
"cedar-lang/{};agentplane-adapter/{ADAPTER_REVISION};extensions=all-available",
cedar_policy::get_lang_version()
)
}
const ADAPTER_CONFIGURATION: &[u8] =
b"principal=Subject|Capability;action=Action;resource=Resource;context=action-schema;rule-name=@id";
pub const RULE_NAME_ANNOTATION: &str = "id";
pub const CONTEXT_NULLS_STRIPPED: &str = "agentplane.policy.context_nulls_stripped";
#[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),
#[error(
"rule '{0}' can never apply to any request, so it governs nothing — \
fix its scope or delete it"
)]
UnreachableRule(String),
#[error(
"two rules answer to the name '{name}' ({first} and {second}) — a denial \
naming it could not say which fired; give each rule its own @id"
)]
AmbiguousRuleName {
name: String,
first: String,
second: 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()))?;
check_rule_names(&policies)?;
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));
}
if let Some(rule) = first_unreachable(&policies, &validation) {
return Err(CedarError::UnreachableRule(rule));
}
(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(r.principal_kind.entity_type(), r.principal)?;
let action = uid("Action", r.action)?;
let resource = uid("Resource", r.resource)?;
let mut removed = 0usize;
let stripped = without_nulls(r.context.clone(), &mut removed);
if removed > 0 {
tracing::debug!(
target: CONTEXT_NULLS_STRIPPED,
action = %r.action,
resource = %r.resource,
removed,
"null values were stripped from the authorization context \
before evaluation; policy saw fewer attributes or array \
elements than the effect key canonicalized"
);
}
let context = Context::from_json_value(
stripped,
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 rule_name(&self, id: &cedar_policy::PolicyId) -> String {
effective_rule_name(&self.policies, id)
}
fn scoped_resources(&self) -> Vec<String> {
use cedar_policy::ResourceConstraint;
let mut named: Vec<String> = self
.policies
.policies()
.filter_map(|policy| match policy.resource_constraint() {
ResourceConstraint::Eq(uid)
| ResourceConstraint::In(uid)
| ResourceConstraint::IsIn(_, uid) => Some(uid),
ResourceConstraint::Any | ResourceConstraint::Is(_) => None,
})
.filter(|uid| uid.type_name().to_string() == "Resource")
.map(|uid| uid.id().unescaped().to_owned())
.collect();
named.sort();
named.dedup();
named
}
}
fn first_unreachable(
policies: &PolicySet,
validation: &cedar_policy::ValidationResult,
) -> Option<String> {
validation.validation_warnings().find_map(|warning| {
matches!(
warning,
cedar_policy::ValidationWarning::InvalidActionApplication(_)
| cedar_policy::ValidationWarning::ImpossiblePolicy(_)
)
.then(|| effective_rule_name(policies, warning.policy_id()))
})
}
fn effective_rule_name(policies: &PolicySet, id: &cedar_policy::PolicyId) -> String {
policies
.annotation(id, RULE_NAME_ANNOTATION)
.map(str::trim)
.filter(|name| !name.is_empty())
.map_or_else(|| id.to_string(), ToOwned::to_owned)
}
fn check_rule_names(policies: &PolicySet) -> Result<(), CedarError> {
let mut seen: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
for policy in policies.policies() {
let generated = policy.id().to_string();
let name = policy
.annotation(RULE_NAME_ANNOTATION)
.map(str::trim)
.filter(|name| !name.is_empty())
.map_or_else(|| generated.clone(), ToOwned::to_owned);
if let Some(first) = seen.insert(name.clone(), generated.clone()) {
return Err(CedarError::AmbiguousRuleName {
name,
first,
second: generated,
});
}
}
Ok(())
}
fn without_nulls(value: Value, removed: &mut usize) -> Value {
match value {
Value::Object(map) => Value::Object(
map.into_iter()
.filter_map(|(k, v)| {
if v.is_null() {
*removed += 1;
None
} else {
Some((k, without_nulls(v, removed)))
}
})
.collect(),
),
Value::Array(items) => Value::Array(
items
.into_iter()
.filter_map(|v| {
if v.is_null() {
*removed += 1;
None
} else {
Some(without_nulls(v, removed))
}
})
.collect(),
),
other => other,
}
}
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::malformed(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::malformed(format!(
"policy evaluation error — refusing because a rule that might \
have forbidden this call failed to evaluate: {} — this is a \
defect in the policy set, not a rule firing",
errors.join("; ")
)),
cedar_policy::Decision::Deny if !errors.is_empty() => {
PolicyDecision::malformed(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(|id| self.rule_name(id))
.collect();
if determining.is_empty() {
PolicyDecision::deny("no policy permits it")
} else {
PolicyDecision::deny(format!("refused by {}", determining.join(", ")))
}
}
}
}
fn preflight(&self, requests: &[PolicyRequest<'_>]) -> Vec<String> {
let mut named = self.scoped_resources();
named.retain(|resource| !requests.iter().any(|r| r.resource == resource));
let mut problems = Vec::new();
for request in requests {
let resources =
std::iter::once(request.resource).chain(named.iter().map(String::as_str));
for resource in resources {
let probe = PolicyRequest {
resource,
..*request
};
let decision = self.authorize(&probe);
if decision.is_malformed() {
problems.push(format!(
"`{}` on `{}`: {}",
probe.action,
probe.resource,
decision.reason().unwrap_or_default()
));
}
}
}
problems
}
fn bundle(&self) -> PolicyBundleIdentity {
self.bundle.clone()
}
}