use std::{env, process::Command, str::FromStr};
use cedar_policy::{PolicySet, RequestEnv, Schema, ValidationMode, Validator};
use cedar_policy_symcc::{
CedarSymCompiler, CompiledPolicySet, always_allows_asserts, always_denies_asserts,
disjoint_asserts,
err::Error as SymccError,
solver::{LocalSolver, Solver},
};
use converge_pack::{
ExecutionIdentity, ExecutionProducerIdentity, FactPayload, NativeExecutionIdentity,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
const CEDAR_SYMCC_VERSION: &str = "0.4";
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
pub const EXPENSE_NON_FINANCE_HIGH_VALUE_COMMIT_CLAIM_POLICY: &str = r#"
permit(principal, action == Action::"commit", resource)
when {
resource.resource_type == "expense" &&
principal.domains.contains("finance") == false &&
principal.authority == "supervisory" &&
context.amount > 5000 &&
context.human_approval_present == true &&
resource.gates_passed.contains("receipt") &&
resource.gates_passed.contains("manager_approval") &&
context.required_gates_met == true
};
"#;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CedarAnalysisQuery {
AlwaysAllows,
AlwaysDenies,
ExpenseNonFinanceHighValueCommitDenied,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CedarAnalysisInput {
pub invariant_id: String,
pub query: CedarAnalysisQuery,
pub policy_source: String,
pub schema_source: String,
}
impl FactPayload for CedarAnalysisInput {
const FAMILY: &'static str = "arbiter.cedar_analysis.input";
const VERSION: u16 = 1;
}
#[async_trait::async_trait]
pub trait CedarAnalysisBackend: Send + Sync {
fn name(&self) -> &'static str;
async fn analyze(
&self,
input: &CedarAnalysisInput,
) -> Result<CedarAnalysisReport, CedarAnalysisError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LocalCvc5AnalysisBackend;
#[async_trait::async_trait]
impl CedarAnalysisBackend for LocalCvc5AnalysisBackend {
fn name(&self) -> &'static str {
"cedar-analysis-local-cvc5"
}
async fn analyze(
&self,
input: &CedarAnalysisInput,
) -> Result<CedarAnalysisReport, CedarAnalysisError> {
let input = input.clone();
tokio::task::spawn_blocking(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|err| CedarAnalysisError::SolverInit(err.to_string()))?;
runtime.block_on(async { execute_analysis_with_cvc5(&input).await })
})
.await
.map_err(|err| CedarAnalysisError::SolverInit(err.to_string()))?
}
}
impl CedarAnalysisInput {
pub fn new(
invariant_id: impl Into<String>,
query: CedarAnalysisQuery,
policy_source: impl Into<String>,
schema_source: impl Into<String>,
) -> Self {
Self {
invariant_id: invariant_id.into(),
query,
policy_source: policy_source.into(),
schema_source: schema_source.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CedarRequestEnvironmentAnalysis {
pub principal_type: String,
pub action: String,
pub resource_type: String,
pub assertion_count: usize,
}
impl CedarRequestEnvironmentAnalysis {
fn from_request_env(request_env: &RequestEnv, assertion_count: usize) -> Self {
Self {
principal_type: request_env.principal().to_string(),
action: request_env.action().to_string(),
resource_type: request_env.resource().to_string(),
assertion_count,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CedarAnalysisPlan {
pub invariant_id: String,
pub query: CedarAnalysisQuery,
pub policy_hash: String,
pub schema_hash: String,
pub query_hash: String,
pub cedar_policy_version: String,
pub cedar_symcc_version: String,
pub policy_count: usize,
pub request_environments: Vec<CedarRequestEnvironmentAnalysis>,
}
impl FactPayload for CedarAnalysisPlan {
const FAMILY: &'static str = "arbiter.cedar_analysis.plan";
const VERSION: u16 = 1;
}
impl CedarAnalysisPlan {
pub fn request_env_count(&self) -> usize {
self.request_environments.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CedarAnalysisExecutionStatus {
NoViolation,
CounterexampleFound,
Unknown,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CedarAnalysisCheck {
pub environment: CedarRequestEnvironmentAnalysis,
pub status: CedarAnalysisExecutionStatus,
pub counterexample: Option<String>,
pub diagnostics: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CedarAnalysisReport {
pub plan: CedarAnalysisPlan,
pub execution_identity: ExecutionIdentity,
pub status: CedarAnalysisExecutionStatus,
pub checks: Vec<CedarAnalysisCheck>,
}
impl FactPayload for CedarAnalysisReport {
const FAMILY: &'static str = "arbiter.cedar_analysis.report";
const VERSION: u16 = 2;
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CedarAnalysisError {
#[error("invariant id must not be empty")]
MissingInvariantId,
#[error("failed to parse Cedar schema: {0}")]
SchemaParse(String),
#[error("failed to parse Cedar policy set: {0}")]
PolicyParse(String),
#[error("failed Cedar policy validation: {0}")]
PolicyValidation(String),
#[error("schema did not produce any request environments")]
NoRequestEnvironments,
#[error("failed to compile Cedar symbolic analysis: {0}")]
Compile(String),
#[error("failed to initialize Cedar symbolic-analysis solver: {0}")]
SolverInit(String),
}
pub fn compile_analysis_plan(
input: &CedarAnalysisInput,
) -> Result<CedarAnalysisPlan, CedarAnalysisError> {
if input.invariant_id.trim().is_empty() {
return Err(CedarAnalysisError::MissingInvariantId);
}
let schema = parse_schema(&input.schema_source)?;
let policies = parse_policy_set(&input.policy_source)?;
validate_policy_set(&schema, &policies)?;
let query_policies = parse_query_policy_set(input.query)?;
if let Some(query_policies) = &query_policies {
validate_policy_set(&schema, query_policies)?;
}
let request_environments = schema
.request_envs()
.map(|request_env| {
let compiled = CompiledPolicySet::compile(&policies, &request_env, &schema)
.map_err(|err| CedarAnalysisError::Compile(format!("{err:?}")))?;
let assertion_count = match input.query {
CedarAnalysisQuery::AlwaysAllows => {
always_allows_asserts(&compiled).asserts().len()
}
CedarAnalysisQuery::AlwaysDenies => {
always_denies_asserts(&compiled).asserts().len()
}
CedarAnalysisQuery::ExpenseNonFinanceHighValueCommitDenied => {
let query_policies = query_policies.as_ref().ok_or_else(|| {
CedarAnalysisError::Compile(
"conditional expense claim policy was not prepared".to_string(),
)
})?;
let compiled_query =
CompiledPolicySet::compile(query_policies, &request_env, &schema)
.map_err(|err| CedarAnalysisError::Compile(format!("{err:?}")))?;
disjoint_asserts(&compiled, &compiled_query).asserts().len()
}
};
Ok(CedarRequestEnvironmentAnalysis::from_request_env(
&request_env,
assertion_count,
))
})
.collect::<Result<Vec<_>, CedarAnalysisError>>()?;
if request_environments.is_empty() {
return Err(CedarAnalysisError::NoRequestEnvironments);
}
Ok(CedarAnalysisPlan {
invariant_id: input.invariant_id.clone(),
query: input.query,
policy_hash: stable_hash(&input.policy_source),
schema_hash: stable_hash(&input.schema_source),
query_hash: stable_query_hash(&input.invariant_id, input.query),
cedar_policy_version: cedar_policy::get_sdk_version().to_string(),
cedar_symcc_version: CEDAR_SYMCC_VERSION.to_string(),
policy_count: policies.policies().count(),
request_environments,
})
}
pub async fn execute_analysis_with_solver<S: Solver>(
input: &CedarAnalysisInput,
solver: S,
) -> Result<CedarAnalysisReport, CedarAnalysisError> {
execute_analysis_with_solver_and_identity(
input,
solver,
non_native_analysis_identity(input, "caller-supplied-symcc-solver"),
)
.await
}
pub async fn execute_analysis_with_solver_and_identity<S: Solver>(
input: &CedarAnalysisInput,
solver: S,
execution_identity: ExecutionIdentity,
) -> Result<CedarAnalysisReport, CedarAnalysisError> {
let plan = compile_analysis_plan(input)?;
let schema = parse_schema(&input.schema_source)?;
let policies = parse_policy_set(&input.policy_source)?;
validate_policy_set(&schema, &policies)?;
let query_policies = parse_query_policy_set(input.query)?;
if let Some(query_policies) = &query_policies {
validate_policy_set(&schema, query_policies)?;
}
let mut compiler = CedarSymCompiler::new(solver)
.map_err(|err| CedarAnalysisError::SolverInit(err.to_string()))?;
let mut checks = Vec::with_capacity(plan.request_env_count());
for request_env in schema.request_envs() {
let compiled = CompiledPolicySet::compile(&policies, &request_env, &schema)
.map_err(|err| CedarAnalysisError::Compile(format!("{err:?}")))?;
let solver_result = match input.query {
CedarAnalysisQuery::AlwaysAllows => {
let assertions = always_allows_asserts(&compiled);
compiler.check_sat(&assertions).await
}
CedarAnalysisQuery::AlwaysDenies => {
let assertions = always_denies_asserts(&compiled);
compiler.check_sat(&assertions).await
}
CedarAnalysisQuery::ExpenseNonFinanceHighValueCommitDenied => {
let query_policies = query_policies.as_ref().ok_or_else(|| {
CedarAnalysisError::Compile(
"conditional expense claim policy was not prepared".to_string(),
)
})?;
let compiled_query =
CompiledPolicySet::compile(query_policies, &request_env, &schema)
.map_err(|err| CedarAnalysisError::Compile(format!("{err:?}")))?;
let assertions = disjoint_asserts(&compiled, &compiled_query);
compiler.check_sat(&assertions).await
}
};
let environment = CedarRequestEnvironmentAnalysis::from_request_env(
&request_env,
plan.request_environments[checks.len()].assertion_count,
);
let check = match solver_result {
Ok(None) => CedarAnalysisCheck {
environment,
status: CedarAnalysisExecutionStatus::NoViolation,
counterexample: None,
diagnostics: None,
},
Ok(Some(counterexample)) => CedarAnalysisCheck {
environment,
status: CedarAnalysisExecutionStatus::CounterexampleFound,
counterexample: Some(format!("{counterexample:#?}")),
diagnostics: None,
},
Err(SymccError::SolverUnknown) => CedarAnalysisCheck {
environment,
status: CedarAnalysisExecutionStatus::Unknown,
counterexample: None,
diagnostics: Some("solver returned unknown".to_string()),
},
Err(err) => CedarAnalysisCheck {
environment,
status: CedarAnalysisExecutionStatus::Error,
counterexample: None,
diagnostics: Some(err.to_string()),
},
};
checks.push(check);
}
Ok(CedarAnalysisReport {
status: overall_status(&checks),
execution_identity,
plan,
checks,
})
}
pub async fn execute_analysis_with_cvc5(
input: &CedarAnalysisInput,
) -> Result<CedarAnalysisReport, CedarAnalysisError> {
let solver =
LocalSolver::cvc5().map_err(|err| CedarAnalysisError::SolverInit(err.to_string()))?;
execute_analysis_with_solver_and_identity(input, solver, cvc5_analysis_identity(input)).await
}
fn non_native_analysis_identity(input: &CedarAnalysisInput, backend: &str) -> ExecutionIdentity {
ExecutionIdentity::non_native(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
backend,
analysis_runtime_config(input),
)
}
fn cvc5_analysis_identity(input: &CedarAnalysisInput) -> ExecutionIdentity {
let version = local_cvc5_version();
ExecutionIdentity::new(
ExecutionProducerIdentity::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
"cvc5",
version.clone(),
"external_process=true",
analysis_runtime_config(input),
Some(NativeExecutionIdentity::new(
"CVC5",
version.clone(),
"https://github.com/cvc5/cvc5",
"external",
version,
"external-process",
)),
)
}
fn analysis_runtime_config(input: &CedarAnalysisInput) -> String {
format!(
"invariant_id={}; query={}; policy_hash={}; schema_hash={}; query_hash={}",
input.invariant_id,
input.query.stable_label(),
stable_hash(&input.policy_source),
stable_hash(&input.schema_source),
stable_query_hash(&input.invariant_id, input.query)
)
}
fn local_cvc5_version() -> String {
let executable = env::var("CVC5").unwrap_or_else(|_| "cvc5".to_string());
Command::new(executable)
.arg("--version")
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout).ok()
} else {
None
}
})
.and_then(|stdout| stdout.lines().next().map(str::trim).map(str::to_string))
.filter(|version| !version.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
fn overall_status(checks: &[CedarAnalysisCheck]) -> CedarAnalysisExecutionStatus {
if checks
.iter()
.any(|check| check.status == CedarAnalysisExecutionStatus::Error)
{
CedarAnalysisExecutionStatus::Error
} else if checks
.iter()
.any(|check| check.status == CedarAnalysisExecutionStatus::CounterexampleFound)
{
CedarAnalysisExecutionStatus::CounterexampleFound
} else if checks
.iter()
.any(|check| check.status == CedarAnalysisExecutionStatus::Unknown)
{
CedarAnalysisExecutionStatus::Unknown
} else {
CedarAnalysisExecutionStatus::NoViolation
}
}
fn parse_schema(source: &str) -> Result<Schema, CedarAnalysisError> {
Schema::from_cedarschema_str(source)
.map(|(schema, _warnings)| schema)
.map_err(|err| CedarAnalysisError::SchemaParse(format!("{err:?}")))
}
fn parse_policy_set(source: &str) -> Result<PolicySet, CedarAnalysisError> {
PolicySet::from_str(source).map_err(|err| CedarAnalysisError::PolicyParse(format!("{err:?}")))
}
fn parse_query_policy_set(
query: CedarAnalysisQuery,
) -> Result<Option<PolicySet>, CedarAnalysisError> {
match query {
CedarAnalysisQuery::AlwaysAllows | CedarAnalysisQuery::AlwaysDenies => Ok(None),
CedarAnalysisQuery::ExpenseNonFinanceHighValueCommitDenied => {
parse_policy_set(EXPENSE_NON_FINANCE_HIGH_VALUE_COMMIT_CLAIM_POLICY).map(Some)
}
}
}
fn validate_policy_set(schema: &Schema, policies: &PolicySet) -> Result<(), CedarAnalysisError> {
let result = Validator::new(schema.clone()).validate(policies, ValidationMode::Strict);
if result.validation_passed() {
Ok(())
} else {
Err(CedarAnalysisError::PolicyValidation(format!("{result:?}")))
}
}
fn stable_hash(value: &str) -> String {
format_hash(fnv1a([value.as_bytes()]))
}
fn stable_query_hash(invariant_id: &str, query: CedarAnalysisQuery) -> String {
match query.policy_source() {
Some(policy_source) => format_hash(fnv1a([
invariant_id.as_bytes(),
b"\0",
query.stable_label().as_bytes(),
b"\0",
policy_source.as_bytes(),
])),
None => format_hash(fnv1a([
invariant_id.as_bytes(),
b"\0",
query.stable_label().as_bytes(),
])),
}
}
impl CedarAnalysisQuery {
#[must_use]
pub const fn claim_policy_source(self) -> Option<&'static str> {
self.policy_source()
}
const fn stable_label(self) -> &'static str {
match self {
Self::AlwaysAllows => "always_allows",
Self::AlwaysDenies => "always_denies",
Self::ExpenseNonFinanceHighValueCommitDenied => {
"expense_non_finance_high_value_commit_denied"
}
}
}
const fn policy_source(self) -> Option<&'static str> {
match self {
Self::AlwaysAllows | Self::AlwaysDenies => None,
Self::ExpenseNonFinanceHighValueCommitDenied => {
Some(EXPENSE_NON_FINANCE_HIGH_VALUE_COMMIT_CLAIM_POLICY)
}
}
}
}
fn fnv1a<const N: usize>(parts: [&[u8]; N]) -> u64 {
let mut hash = FNV_OFFSET_BASIS;
for part in parts {
for byte in part {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
}
hash
}
fn format_hash(hash: u64) -> String {
format!("fnv1a64:{hash:016x}")
}