use affinidi_status_list::StatusPurpose;
use affinidi_vc::VerifiableCredential;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::info;
use uuid::Uuid;
use vti_common::audit::{
AuditEvent, CredentialIssuedData, JoinRequestData, JoinRequestRejectedData, MemberAddedData,
};
use vti_common::error::AppError;
use crate::acl::{VtcAclEntry, VtcRole, get_acl_entry, store_acl_entry};
use crate::auth::AdminAuth;
use crate::auth::session::now_epoch;
use crate::credentials::vec::VEC_TYPE;
use crate::credentials::vmc::VMC_TYPE;
use crate::credentials::{
CredentialStatusRef, RoleVecParams, VmcParams, build_role_vec, build_vmc,
};
use crate::join::{JoinStatus, get_join_request, store_join_request};
use crate::members::{Member, store_member};
use crate::server::AppState;
use crate::status_list;
const REJECT_REASON_MAX: usize = 1024;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DecideResponse {
pub request_id: Uuid,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vmc: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_vec: Option<JsonValue>,
}
pub async fn approve(
admin: AdminAuth,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<(StatusCode, Json<DecideResponse>), AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let mut req = get_join_request(&state.join_requests_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("join request not found: {id}")))?;
if req.status != JoinStatus::Pending {
return Err(AppError::Conflict(format!(
"join request {id} is {:?}, not Pending",
req.status
)));
}
if get_acl_entry(&state.acl_ks, &req.applicant_did)
.await?
.is_some()
{
return Err(AppError::Conflict(format!(
"{} already has an ACL row; refusing to approve a duplicate membership",
req.applicant_did
)));
}
let now = now_epoch();
let acl = VtcAclEntry {
did: req.applicant_did.clone(),
role: VtcRole::Member,
label: None,
allowed_contexts: vec![],
created_at: now,
created_by: admin.0.did.clone(),
expires_at: None,
};
store_acl_entry(&state.acl_ks, &acl).await?;
let mut member = Member::fresh(&req.applicant_did);
store_member(&state.members_ks, &member).await?;
let (vmc, role_vec, status_list_index) =
issue_join_credentials(&state, &req.applicant_did).await?;
member.status_list_index = Some(status_list_index);
member.current_vmc_id = top_level_id(&vmc);
member.current_role_vec_id = top_level_id(&role_vec);
store_member(&state.members_ks, &member).await?;
req.status = JoinStatus::Approved;
store_join_request(&state.join_requests_ks, &req).await?;
audit_writer
.write(
&admin.0.did,
Some(&req.applicant_did),
AuditEvent::JoinRequestApproved(JoinRequestData {
request_id: id.to_string(),
transport: "rest".to_string(),
}),
)
.await?;
audit_writer
.write(
&admin.0.did,
Some(&req.applicant_did),
AuditEvent::MemberAdded(MemberAddedData {
role: VtcRole::Member.to_string(),
via_join_request_id: Some(id.to_string()),
}),
)
.await?;
audit_writer
.write(
&admin.0.did,
Some(&req.applicant_did),
AuditEvent::VmcIssued(credential_issued_data(&vmc, Some(status_list_index))?),
)
.await?;
audit_writer
.write(
&admin.0.did,
Some(&req.applicant_did),
AuditEvent::VecIssued(credential_issued_data(&role_vec, None)?),
)
.await?;
info!(
request_id = %id,
applicant = %req.applicant_did,
admin = %admin.0.did,
status_list_index,
"join request approved"
);
Ok((
StatusCode::OK,
Json(DecideResponse {
request_id: id,
status: req.status.to_string(),
vmc: Some(
serde_json::to_value(&vmc)
.map_err(|e| AppError::Internal(format!("serialise VMC for response: {e}")))?,
),
role_vec: Some(
serde_json::to_value(&role_vec)
.map_err(|e| AppError::Internal(format!("serialise VEC for response: {e}")))?,
),
}),
))
}
async fn issue_join_credentials(
state: &AppState,
applicant_did: &str,
) -> Result<(VerifiableCredential, VerifiableCredential, u32), AppError> {
let signer = state.credential_signer.as_ref().ok_or_else(|| {
AppError::Internal(
"credential signer not initialised — cannot mint VMC (run setup first)".into(),
)
})?;
let mut row = status_list::get_state(&state.status_lists_ks, StatusPurpose::Revocation)
.await?
.ok_or_else(|| {
AppError::Internal(
"revocation status list not provisioned — set `public_url` + restart".into(),
)
})?;
let slot = status_list::allocate(&mut row).ok_or_else(|| {
AppError::Internal(format!(
"revocation status list exhausted (capacity = {})",
row.capacity
))
})?;
let status_ref = CredentialStatusRef::revocation(row.list_credential_id.clone(), slot);
let vmc_id = format!("urn:uuid:{}", Uuid::new_v4());
let vmc = build_vmc(
signer,
VmcParams::new(applicant_did)
.with_id(vmc_id)
.with_status_ref(status_ref)
.with_personhood(false),
)
.await?;
let vec_id = format!("urn:uuid:{}", Uuid::new_v4());
let role_vec = build_role_vec(
signer,
RoleVecParams::new(applicant_did, VtcRole::Member).with_id(vec_id),
)
.await?;
status_list::store_state(&state.status_lists_ks, &row).await?;
status_list::maybe_emit_occupancy_warning(&row);
Ok((vmc, role_vec, slot))
}
fn top_level_id(vc: &VerifiableCredential) -> Option<String> {
serde_json::to_value(vc)
.ok()
.and_then(|v| v.get("id").and_then(|i| i.as_str().map(str::to_string)))
}
fn credential_issued_data(
vc: &VerifiableCredential,
status_list_index: Option<u32>,
) -> Result<CredentialIssuedData, AppError> {
let id = top_level_id(vc).ok_or_else(|| {
AppError::Internal("credential is missing top-level `id` — issuance dropped it".into())
})?;
let credential_type = vc
.types
.iter()
.find(|t| *t == VMC_TYPE || *t == VEC_TYPE)
.cloned()
.ok_or_else(|| AppError::Internal("credential carries neither VMC nor VEC type".into()))?;
let valid_from = vc
.valid_from
.clone()
.ok_or_else(|| AppError::Internal("credential missing validFrom".into()))?;
let valid_until = vc
.valid_until
.clone()
.ok_or_else(|| AppError::Internal("credential missing validUntil".into()))?;
Ok(CredentialIssuedData {
credential_id: id,
credential_type,
valid_from,
valid_until,
status_list_index,
})
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RejectBody {
#[serde(default)]
pub reason: Option<String>,
}
pub async fn reject(
admin: AdminAuth,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(body): Json<RejectBody>,
) -> Result<(StatusCode, Json<DecideResponse>), AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let reason = body.reason.unwrap_or_default();
if reason.len() > REJECT_REASON_MAX {
return Err(AppError::Validation(format!(
"reject reason exceeds {REJECT_REASON_MAX} chars (got {})",
reason.len(),
)));
}
let mut req = get_join_request(&state.join_requests_ks, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("join request not found: {id}")))?;
if req.status != JoinStatus::Pending {
return Err(AppError::Conflict(format!(
"join request {id} is {:?}, not Pending",
req.status
)));
}
req.status = JoinStatus::Rejected;
store_join_request(&state.join_requests_ks, &req).await?;
audit_writer
.write(
&admin.0.did,
Some(&req.applicant_did),
AuditEvent::JoinRequestRejected(JoinRequestRejectedData {
request_id: id.to_string(),
reason: reason.clone(),
}),
)
.await?;
info!(
request_id = %id,
applicant = %req.applicant_did,
admin = %admin.0.did,
reason_present = !reason.is_empty(),
"join request rejected"
);
Ok((
StatusCode::OK,
Json(DecideResponse {
request_id: id,
status: req.status.to_string(),
vmc: None,
role_vec: None,
}),
))
}