use std::sync::LazyLock;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
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::auth::AdminAuth;
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,
};
use crate::server::AppState;
static ACTIVATE_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UploadBody {
pub purpose: PolicyPurpose,
pub rego_source: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UploadResponse {
pub id: Uuid,
pub sha256: String,
pub purpose: PolicyPurpose,
pub version: u32,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ActivateResponse {
pub id: Uuid,
pub purpose: PolicyPurpose,
pub sha256: String,
pub previous_policy_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TestBody {
pub query: String,
pub input: JsonValue,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TestResponse {
pub id: Uuid,
pub purpose: PolicyPurpose,
pub sha256: String,
pub result: JsonValue,
}
pub async fn upload(
admin: AdminAuth,
State(state): State<AppState>,
Json(body): Json<UploadBody>,
) -> Result<(StatusCode, Json<UploadResponse>), AppError> {
if body.rego_source.len() > POLICY_SOURCE_MAX_BYTES {
return Err(AppError::Validation(format!(
"rego_source exceeds {POLICY_SOURCE_MAX_BYTES} bytes (got {})",
body.rego_source.len(),
)));
}
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let id = Uuid::new_v4();
let compiled = compile(&body.rego_source, id)?;
let sha256 = *compiled.source_sha256();
let version = max_version_for(&state.policies_ks, body.purpose).await? + 1;
let mut policy = new_policy(
body.purpose,
body.rego_source,
sha256,
admin.0.did.clone(),
version,
);
policy.id = id;
store_policy(&state.policies_ks, &policy).await?;
let sha256_hex = hex::encode(sha256);
audit_writer
.write(
&admin.0.did,
None,
AuditEvent::PolicyUploaded(PolicyUploadedData {
policy_id: id.to_string(),
purpose: body.purpose.as_str().to_string(),
sha256: sha256_hex.clone(),
version,
}),
)
.await?;
info!(
actor = admin.0.did.as_str(),
policy_id = %id,
purpose = body.purpose.as_str(),
version,
sha256 = sha256_hex.as_str(),
"policy uploaded"
);
Ok((
StatusCode::CREATED,
Json(UploadResponse {
id,
sha256: sha256_hex,
purpose: body.purpose,
version,
}),
))
}
pub async fn activate(
admin: AdminAuth,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<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}")))?;
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?;
let sha256_hex = hex::encode(policy.sha256);
audit_writer
.write(
&admin.0.did,
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 = admin.0.did.as_str(),
policy_id = %id,
purpose = policy.purpose.as_str(),
previous = ?previous,
"policy activated"
);
Ok(Json(ActivateResponse {
id,
purpose: policy.purpose,
sha256: sha256_hex,
previous_policy_id: previous,
}))
}
pub async fn test(
admin: AdminAuth,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<TestBody>,
) -> Result<Json<TestResponse>, AppError> {
let policy = get_policy(&state.policies_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy not found: {id}")))?;
let compiled = compile(&policy.rego_source, policy.id)?;
let result = evaluate(&compiled, &body.query, body.input)?;
info!(
actor = admin.0.did.as_str(),
policy_id = %id,
purpose = policy.purpose.as_str(),
"policy tested"
);
Ok(Json(TestResponse {
id,
purpose: policy.purpose,
sha256: hex::encode(policy.sha256),
result,
}))
}