use std::collections::HashSet;
use axum::Json;
use axum::extract::{Path, Query, State};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use vti_common::error::AppError;
use vti_common::pagination::{Cursor, MAX_LIMIT};
use crate::auth::AdminAuth;
use crate::policy::{
Policy, PolicyPurpose, get_active_policy_id, get_policy, list_policies_paginated,
};
use crate::server::AppState;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct PolicyResponse {
#[serde(flatten)]
pub policy: Policy,
pub is_active: bool,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PolicyListResponse {
pub policies: Vec<PolicyModuleResponse>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PolicyGetResponse {
pub policy: PolicyModuleResponse,
}
pub const PURPOSE_EXT_KEY: &str = "org.openvtc.purpose";
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PolicyModuleResponse {
pub id: Uuid,
pub name: String,
pub module: String,
pub version: u32,
pub created_at: String,
pub updated_at: String,
pub ext: serde_json::Value,
}
impl From<&Policy> for PolicyModuleResponse {
fn from(p: &Policy) -> Self {
Self {
id: p.id,
name: p
.name
.clone()
.unwrap_or_else(|| p.purpose.as_str().to_owned()),
module: p.rego_source.clone(),
version: p.version,
created_at: p.created_at.to_rfc3339(),
updated_at: p.activated_at.unwrap_or(p.created_at).to_rfc3339(),
ext: serde_json::json!({
PURPOSE_EXT_KEY: p.purpose.as_str(),
"org.openvtc.sha256": hex::encode(p.sha256),
"org.openvtc.authorDid": p.author_did,
}),
}
}
}
impl PolicyResponse {
fn from_policy(policy: Policy, is_active: bool) -> Self {
Self { policy, is_active }
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema, utoipa::IntoParams)]
pub struct ListPoliciesQuery {
pub purpose: Option<PolicyPurpose>,
pub context_id: Option<String>,
pub enabled_only: Option<bool>,
pub page_size: Option<usize>,
pub status: Option<PolicyStatusFilter>,
pub cursor: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(utoipa::ToSchema)]
pub enum PolicyStatusFilter {
Active,
Archived,
}
#[utoipa::path(
get, path = "/policies", tag = "policies",
security(("bearer_jwt" = [])),
params(ListPoliciesQuery),
responses(
(status = 200, description = "Paginated list of policies", body = PolicyListResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin"),
),
)]
pub async fn list_policies(
_auth: AdminAuth,
State(state): State<AppState>,
Query(query): Query<ListPoliciesQuery>,
) -> Result<Json<PolicyListResponse>, AppError> {
let mut unsupported = Vec::new();
if query.context_id.is_some() {
unsupported.push("contextId");
}
if query.enabled_only.is_some() {
unsupported.push("enabledOnly");
}
if !unsupported.is_empty() {
return Err(AppError::Validation(format!(
"this maintainer does not implement the {} filter(s): its policy log \
is not context-partitioned and modules carry no enabled flag. \
Refusing rather than returning an unfiltered list.",
unsupported.join(", "),
)));
}
if matches!(query.status, Some(PolicyStatusFilter::Active)) {
let mut items = Vec::new();
for purpose in PolicyPurpose::ALL {
if query.purpose.is_some_and(|f| f != purpose) {
continue;
}
if let Some(id) = get_active_policy_id(&state.active_policies_ks, purpose).await?
&& let Some(p) = get_policy(&state.policies_ks, id).await?
{
items.push(PolicyResponse::from_policy(p, true));
}
}
return Ok(Json(PolicyListResponse {
policies: items.iter().map(|r| (&r.policy).into()).collect(),
truncated: false,
cursor: None,
}));
}
let limit = query
.page_size
.or(query.limit)
.unwrap_or(50)
.clamp(1, MAX_LIMIT);
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let audit_key = audit_writer.active_key().await?;
let decoded_cursor = match &query.cursor {
Some(s) => Some(Cursor::decode(s, &audit_key.key)?),
None => None,
};
let page = list_policies_paginated(
&state.policies_ks,
&audit_key,
decoded_cursor.as_ref(),
limit,
)
.await?;
let mut active_ids: HashSet<Uuid> = HashSet::new();
for purpose in PolicyPurpose::ALL {
if let Some(id) = get_active_policy_id(&state.active_policies_ks, purpose).await? {
active_ids.insert(id);
}
}
let purpose_filter = query.purpose;
let status_filter = query.status;
let items: Vec<PolicyResponse> = page
.items
.into_iter()
.filter(|p| purpose_filter.is_none_or(|f| p.purpose == f))
.filter_map(|p| {
let is_active = active_ids.contains(&p.id);
match status_filter {
Some(PolicyStatusFilter::Active) if !is_active => return None,
Some(PolicyStatusFilter::Archived) if is_active => return None,
_ => {}
}
Some(PolicyResponse::from_policy(p, is_active))
})
.collect();
Ok(Json(PolicyListResponse {
policies: items.iter().map(|r| (&r.policy).into()).collect(),
truncated: page.next_cursor.is_some(),
cursor: page.next_cursor,
}))
}
#[utoipa::path(
get, path = "/policies/{id}", tag = "policies",
security(("bearer_jwt" = [])),
params(("id" = String, Path, description = "Policy id")),
responses(
(status = 200, description = "Policy", body = PolicyGetResponse),
(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 show_policy(
_auth: AdminAuth,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<PolicyGetResponse>, AppError> {
let policy = get_policy(&state.policies_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("policy not found: {id}")))?;
Ok(Json(PolicyGetResponse {
policy: (&policy).into(),
}))
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ActiveBindingsResponse {
pub bindings: Vec<ActiveBinding>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ActiveBinding {
pub purpose: String,
pub policy: PolicyModuleResponse,
}
#[derive(Debug, Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
#[into_params(parameter_in = Query)]
pub struct ActiveQuery {
pub purpose: Option<PolicyPurpose>,
pub context_id: Option<String>,
}
#[utoipa::path(
get, path = "/policies/active", tag = "policies",
security(("bearer_jwt" = [])),
params(ActiveQuery),
responses(
(status = 200, description = "Active policy bindings", body = ActiveBindingsResponse),
(status = 401, description = "Missing or invalid bearer token"),
(status = 403, description = "Caller is not an admin"),
),
)]
pub async fn active_policies(
_auth: AdminAuth,
State(state): State<AppState>,
Query(query): Query<ActiveQuery>,
) -> Result<Json<ActiveBindingsResponse>, AppError> {
if query.context_id.is_some() {
return Err(AppError::Validation(
"this maintainer does not implement the contextId filter: a VTC is a \
single community and its policy bindings are community-wide."
.into(),
));
}
let mut bindings = Vec::new();
for purpose in PolicyPurpose::ALL {
if query.purpose.is_some_and(|f| f != purpose) {
continue;
}
if let Some(id) = get_active_policy_id(&state.active_policies_ks, purpose).await?
&& let Some(p) = get_policy(&state.policies_ks, id).await?
{
bindings.push(ActiveBinding {
purpose: purpose.as_str().to_owned(),
policy: (&p).into(),
});
}
}
Ok(Json(ActiveBindingsResponse { bindings }))
}