use std::fmt;
use std::num::NonZeroU32;
use std::time::Duration;
use regorus::utils::limits::{ExecutionTimerConfig, LimitError};
use regorus::{Engine, Value as RegoValue};
use sha2::{Digest, Sha256};
use vti_common::error::AppError;
use super::types::{PolicyDecision, PolicyInput};
const POLICY_EVAL_TIME_LIMIT: Duration = Duration::from_millis(250);
const POLICY_EVAL_CHECK_INTERVAL: u32 = 1000;
const MAX_POLICY_INPUT_BYTES: usize = 256 * 1024;
pub const POLICY_MODULE_PATH: &str = "policy.rego";
pub const DECISION_QUERY: &str = "data.vta.policy.decision";
#[derive(Clone)]
pub struct CompiledPolicy {
id: String,
source_sha256: [u8; 32],
engine: Engine,
}
impl fmt::Debug for CompiledPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CompiledPolicy")
.field("id", &self.id)
.field("source_sha256", &hex::encode(self.source_sha256))
.finish_non_exhaustive()
}
}
impl CompiledPolicy {
pub fn id(&self) -> &str {
&self.id
}
pub fn source_sha256(&self) -> &[u8; 32] {
&self.source_sha256
}
}
pub fn compile(rego_source: &str, id: &str) -> Result<CompiledPolicy, AppError> {
let mut engine = Engine::new();
engine
.add_policy(POLICY_MODULE_PATH.to_string(), rego_source.to_string())
.map_err(|e| AppError::Validation(format!("rego compile failed for policy {id}: {e}")))?;
let source_sha256: [u8; 32] = Sha256::digest(rego_source.as_bytes()).into();
Ok(CompiledPolicy {
id: id.to_string(),
source_sha256,
engine,
})
}
pub fn evaluate_decision(
compiled: &CompiledPolicy,
input: &PolicyInput,
) -> Result<Option<PolicyDecision>, AppError> {
let input_json = serde_json::to_value(input)?;
let input_bytes = serde_json::to_vec(&input_json)?;
if input_bytes.len() > MAX_POLICY_INPUT_BYTES {
return Err(AppError::ResourceExhausted(format!(
"policy input ({} bytes) exceeds the {MAX_POLICY_INPUT_BYTES}-byte cap",
input_bytes.len()
)));
}
let mut engine = compiled.engine.clone();
engine.set_execution_timer_config(ExecutionTimerConfig {
limit: POLICY_EVAL_TIME_LIMIT,
check_interval: NonZeroU32::new(POLICY_EVAL_CHECK_INTERVAL)
.expect("POLICY_EVAL_CHECK_INTERVAL is non-zero"),
});
engine.set_input(RegoValue::from(input_json));
let results = engine
.eval_query(DECISION_QUERY.to_string(), false)
.map_err(|e| {
if e.downcast_ref::<LimitError>().is_some() {
AppError::ResourceExhausted(format!(
"policy {} evaluation exceeded its resource budget",
compiled.id
))
} else {
AppError::Internal(format!(
"rego evaluation failed for policy {}: {e}",
compiled.id
))
}
})?;
let raw = serde_json::to_value(results)?;
let value = raw.pointer("/result/0/expressions/0/value");
match value {
None => Ok(None),
Some(v)
if v.is_null() || (v.is_object() && v.as_object().is_some_and(|m| m.is_empty())) =>
{
Ok(None)
}
Some(v) => {
let decision: PolicyDecision = serde_json::from_value(v.clone()).map_err(|e| {
AppError::Internal(format!(
"policy {} returned a decision that does not match PolicyDecision: {e}",
compiled.id
))
})?;
Ok(Some(decision))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
Consumer, Discloses, Disposition, Exposure, PolicyRequest, SideEffectLevel,
};
fn input(side_effects: SideEffectLevel) -> PolicyInput {
PolicyInput {
request: PolicyRequest {
type_uri: "https://trusttasks.org/spec/did-management/did/delete/0.1".into(),
kind: None,
subject: Some("did:webvh:abc".into()),
payload_digest: None,
side_effects,
exposure: Exposure {
discloses: Discloses::None,
acts_as_subject: false,
},
},
site: None,
context_id: "ctx1".into(),
consumer: Consumer {
did: "did:key:zTest".into(),
kind: None,
device_id: None,
last_user_verification_at: None,
network_class: None,
acr: None,
amr: vec![],
},
}
}
#[test]
fn compile_surfaces_parse_error() {
let err = compile("package vta.policy\ndecision := {", "bad").unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn destructive_denied_by_a_simple_policy() {
let src = r#"
package vta.policy
import rego.v1
decision := {"decision": "deny", "explanation": "destructive tasks are blocked"} if {
input.request.sideEffects == "destructive"
}
"#;
let p = compile(src, "p1").unwrap();
let d = evaluate_decision(&p, &input(SideEffectLevel::Destructive))
.unwrap()
.expect("decision rule should fire for a destructive task");
assert_eq!(d.decision, Disposition::Deny);
}
#[test]
fn abstains_when_rule_does_not_fire() {
let src = r#"
package vta.policy
import rego.v1
decision := {"decision": "deny"} if input.request.sideEffects == "destructive"
"#;
let p = compile(src, "p1").unwrap();
let out = evaluate_decision(&p, &input(SideEffectLevel::None)).unwrap();
assert!(out.is_none(), "non-matching policy must abstain, not deny");
}
}