use std::sync::LazyLock;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tokio::sync::Mutex;
use tracing::info;
use uuid::Uuid;
use vti_common::audit::{AuditEvent, PolicyActivatedData, PolicyUploadedData};
use vti_common::error::AppError;
use crate::error::TaskError;
use crate::policy::POLICY_SOURCE_MAX_BYTES;
use crate::policy::{
PolicyPurpose, compile, evaluate, get_active_policy_id, get_policy, max_version_for,
new_policy, set_active_policy_id, store_policy, validate_purpose_package,
};
use crate::server::AppState;
static ACTIVATE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UploadBody {
pub name: String,
pub module: String,
#[serde(default)]
pub expected_version: Option<u32>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub id: Option<Uuid>,
#[serde(default)]
pub applies_to: Option<Vec<String>>,
#[serde(default)]
pub priority: Option<i64>,
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub ext: Option<serde_json::Value>,
}
impl UploadBody {
fn purpose(&self) -> Result<PolicyPurpose, AppError> {
let raw = self
.ext
.as_ref()
.and_then(|e| e.get(crate::routes::policies::read::PURPOSE_EXT_KEY))
.and_then(|v| v.as_str())
.ok_or_else(|| {
AppError::Validation(format!(
"ext.{} is required: this maintainer binds a policy's purpose \
intrinsically (it is fixed by the module's Rego package), so it \
cannot be deferred to activation as canonical policy/upsert assumes",
crate::routes::policies::read::PURPOSE_EXT_KEY,
))
})?;
serde_json::from_value(serde_json::Value::String(raw.to_owned())).map_err(|e| {
AppError::Validation(format!("ext purpose {raw:?} is not a known purpose: {e}"))
})
}
fn unsupported(&self) -> Vec<&'static str> {
let mut out = Vec::new();
if self.applies_to.is_some() {
out.push("appliesTo");
}
if self.priority.is_some() {
out.push("priority");
}
if self.enabled == Some(false) {
out.push("enabled");
}
if self.id.is_some() {
out.push("id");
}
out
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct UploadResponse {
pub policy: crate::routes::policies::read::PolicyModuleResponse,
pub created: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct ActivateResponse {
pub activated: Uuid,
pub purpose: PolicyPurpose,
#[serde(skip_serializing_if = "Option::is_none")]
pub ext: Option<JsonValue>,
pub previous_policy_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct TestBody {
pub query: String,
pub input: JsonValue,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct TestResponse {
pub id: Uuid,
pub purpose: PolicyPurpose,
pub sha256: String,
pub result: JsonValue,
}
pub(crate) fn precheck_upload(body: &UploadBody) -> Result<PolicyPurpose, AppError> {
let unsupported = body.unsupported();
if !unsupported.is_empty() {
return Err(AppError::Validation(format!(
"this maintainer does not implement {}",
unsupported.join(", "),
)));
}
let purpose = body.purpose()?;
if body.module.len() > POLICY_SOURCE_MAX_BYTES {
return Err(AppError::Validation(format!(
"module exceeds {POLICY_SOURCE_MAX_BYTES} bytes (got {})",
body.module.len(),
)));
}
validate_purpose_package(&compile(&body.module, Uuid::new_v4())?, purpose)?;
Ok(purpose)
}
pub(crate) async fn precheck_activate(
state: &AppState,
id: Uuid,
purpose: Option<PolicyPurpose>,
) -> Result<PolicyPurpose, AppError> {
let policy = get_policy(&state.policies_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy not found: {id}")))?;
if let Some(named) = purpose
&& named != policy.purpose
{
return Err(AppError::Validation(format!(
"policy {id} decides {}, not {}: a revision's purpose is fixed by its Rego package",
policy.purpose.as_str(),
named.as_str()
)));
}
validate_purpose_package(&compile(&policy.rego_source, policy.id)?, policy.purpose)?;
if get_active_policy_id(&state.active_policies_ks, policy.purpose).await? == Some(id) {
return Err(AppError::Conflict(format!(
"policy {id} is already active for purpose {}",
policy.purpose.as_str()
)));
}
Ok(policy.purpose)
}
pub(crate) async fn check_upload(state: &AppState, body: &UploadBody) -> Result<(), AppError> {
let unsupported = body.unsupported();
if !unsupported.is_empty() {
return Err(AppError::Validation(format!(
"this maintainer does not implement {}: it selects a policy by its \
activated (purpose) binding, not by appliesTo/priority matching, and \
modules carry no enabled flag. Refusing rather than accepting a \
selection hint that would never be honoured.",
unsupported.join(", "),
)));
}
let purpose = body.purpose()?;
if body.module.len() > POLICY_SOURCE_MAX_BYTES {
return Err(AppError::Validation(format!(
"module exceeds {POLICY_SOURCE_MAX_BYTES} bytes (got {})",
body.module.len(),
)));
}
let compiled = compile(&body.module, Uuid::new_v4())?;
validate_purpose_package(&compiled, purpose)?;
let current_version = max_version_for(&state.policies_ks, purpose).await?;
if let Some(expected) = body.expected_version
&& expected != current_version
{
return Err(AppError::Conflict(format!(
"expectedVersion {expected} does not match the current revision \
{current_version} for purpose {}",
purpose.as_str(),
)));
}
Ok(())
}
pub(crate) async fn upload_inner(
state: &AppState,
actor: &str,
body: UploadBody,
) -> Result<UploadResponse, AppError> {
let unsupported = body.unsupported();
if !unsupported.is_empty() {
return Err(AppError::Validation(format!(
"this maintainer does not implement {}: it selects a policy by its \
activated (purpose) binding, not by appliesTo/priority matching, and \
modules carry no enabled flag. Refusing rather than accepting a \
selection hint that would never be honoured.",
unsupported.join(", "),
)));
}
let purpose = body.purpose()?;
if body.module.len() > POLICY_SOURCE_MAX_BYTES {
return Err(AppError::Validation(format!(
"module exceeds {POLICY_SOURCE_MAX_BYTES} bytes (got {})",
body.module.len(),
)));
}
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let id = crate::admin_actions::executing_revision_id().unwrap_or_else(Uuid::new_v4);
let compiled = compile(&body.module, id)?;
validate_purpose_package(&compiled, purpose)?;
let sha256 = *compiled.source_sha256();
let current_version = max_version_for(&state.policies_ks, purpose).await?;
if let Some(expected) = body.expected_version
&& expected != current_version
{
return Err(AppError::Conflict(format!(
"expectedVersion {expected} does not match the current revision \
{current_version} for purpose {}",
purpose.as_str(),
)));
}
let version = current_version + 1;
let created = current_version == 0;
let mut policy = new_policy(purpose, body.module, sha256, actor.to_string(), version);
policy.id = id;
policy.name = Some(body.name.clone());
policy.description = body.description.clone();
store_policy(&state.policies_ks, &policy).await?;
crate::admin_actions::record_effect(state).await;
let sha256_hex = hex::encode(sha256);
audit_writer
.write(
actor,
None,
AuditEvent::PolicyUploaded(PolicyUploadedData {
policy_id: id.to_string(),
purpose: purpose.as_str().to_string(),
sha256: sha256_hex.clone(),
version,
}),
)
.await?;
info!(
actor = actor,
policy_id = %id,
purpose = purpose.as_str(),
version,
sha256 = sha256_hex.as_str(),
"policy uploaded"
);
Ok(UploadResponse {
policy: (&policy).into(),
created,
})
}
pub(crate) async fn activate_inner(
state: &AppState,
actor: &str,
id: Uuid,
purpose: Option<PolicyPurpose>,
) -> Result<ActivateResponse, AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let _guard = ACTIVATE_LOCK.lock().await;
let mut policy = get_policy(&state.policies_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy not found: {id}")))?;
if let Some(named) = purpose
&& named != policy.purpose
{
return Err(AppError::Validation(format!(
"policy {id} decides {}, not {}: a revision's purpose is fixed by its Rego package",
policy.purpose.as_str(),
named.as_str()
)));
}
validate_purpose_package(&compile(&policy.rego_source, policy.id)?, policy.purpose)?;
let previous = get_active_policy_id(&state.active_policies_ks, policy.purpose).await?;
if previous == Some(id) {
return Err(AppError::Conflict(format!(
"policy {id} is already active for purpose {}",
policy.purpose.as_str()
)));
}
let now = Utc::now();
policy.activated_at = Some(now);
store_policy(&state.policies_ks, &policy).await?;
set_active_policy_id(&state.active_policies_ks, policy.purpose, id).await?;
crate::admin_actions::record_effect(state).await;
let sha256_hex = hex::encode(policy.sha256);
audit_writer
.write(
actor,
None,
AuditEvent::PolicyActivated(PolicyActivatedData {
policy_id: id.to_string(),
purpose: policy.purpose.as_str().to_string(),
sha256: sha256_hex.clone(),
previous_policy_id: previous.map(|p| p.to_string()),
}),
)
.await?;
info!(
actor = actor,
policy_id = %id,
purpose = policy.purpose.as_str(),
previous = ?previous,
"policy activated"
);
Ok(ActivateResponse {
activated: id,
purpose: policy.purpose,
ext: Some(serde_json::json!({
"org.openvtc": { "sha256": sha256_hex }
})),
previous_policy_id: previous,
})
}
use trust_tasks_rs::specs::vtc::policies::test::v0_1::error_codes as test_codes;
pub const TEST_ERR_NOT_FOUND: &str = test_codes::NOT_FOUND.code;
pub const TEST_ERR_EVALUATION_FAILED: &str = test_codes::EVALUATION_FAILED.code;
pub(crate) async fn test(
state: &AppState,
actor: &str,
id: Uuid,
body: TestBody,
) -> Result<TestResponse, TaskError> {
let policy = get_policy(&state.policies_ks, id).await?.ok_or_else(|| {
TaskError::declared(
TEST_ERR_NOT_FOUND,
AppError::NotFound(format!("policy not found: {id}")),
)
})?;
let evaluation_failed = |e: AppError| match e {
e @ (AppError::Validation(_) | AppError::Internal(_)) => {
TaskError::declared(TEST_ERR_EVALUATION_FAILED, e)
}
e => TaskError::App(e),
};
let compiled = compile(&policy.rego_source, policy.id).map_err(evaluation_failed)?;
let result = evaluate(&compiled, &body.query, body.input).map_err(evaluation_failed)?;
info!(
actor = actor,
policy_id = %id,
purpose = policy.purpose.as_str(),
"policy tested"
);
Ok(TestResponse {
id,
purpose: policy.purpose,
sha256: hex::encode(policy.sha256),
result,
})
}