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, 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.is_some() {
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,
pub sha256: String,
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,
}
#[utoipa::path(
post, path = "/policies", tag = "policies",
security(("bearer_jwt" = [])),
request_body = UploadBody,
responses(
(status = 201, description = "Policy revision compiled + stored", body = UploadResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin"),
),
)]
pub async fn upload(
admin: AdminAuth,
State(state): State<AppState>,
Json(body): Json<UploadBody>,
) -> Result<(StatusCode, Json<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 = 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, admin.0.did.clone(), version);
policy.id = id;
policy.name = Some(body.name.clone());
policy.description = body.description.clone();
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: purpose.as_str().to_string(),
sha256: sha256_hex.clone(),
version,
}),
)
.await?;
info!(
actor = admin.0.did.as_str(),
policy_id = %id,
purpose = purpose.as_str(),
version,
sha256 = sha256_hex.as_str(),
"policy uploaded"
);
Ok((
if created {
StatusCode::CREATED
} else {
StatusCode::OK
},
Json(UploadResponse {
policy: (&policy).into(),
created,
}),
))
}
#[utoipa::path(
post, path = "/policies/{id}/activate", tag = "policies",
security(("bearer_jwt" = [])),
params(("id" = String, Path, description = "Policy revision id")),
responses(
(status = 200, description = "Policy revision activated", body = ActivateResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin"),
(status = 404, description = "Policy not found"),
),
)]
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}")))?;
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?;
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 {
activated: id,
purpose: policy.purpose,
sha256: sha256_hex,
previous_policy_id: previous,
}))
}
#[utoipa::path(
post, path = "/policies/{id}/test", tag = "policies",
security(("bearer_jwt" = [])),
params(("id" = String, Path, description = "Policy revision id")),
request_body = TestBody,
responses(
(status = 200, description = "Policy evaluation result", body = TestResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin"),
(status = 404, description = "Policy not found"),
),
)]
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,
}))
}