use affinidi_status_list::StatusPurpose;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::info;
use vti_common::audit::{AuditEvent, MemberRemovedData, StatusListFlippedData};
use vti_common::error::AppError;
use crate::acl::get_acl_entry;
use crate::auth::{AdminAuth, AuthClaims};
use crate::ceremony::execute;
use crate::ceremony::{
Actor, Context, EffectOutcome, EffectPlan, Evidence, Facts, MemberState, Purpose,
State as FactsState, Subject, Verdict, VerifiedFacts,
};
use crate::community::load_profile;
use crate::members::{Disposition, get_member, list_members};
use crate::policy::{PolicyPurpose, load_active_compiled};
use crate::server::AppState;
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RemoveBody {
#[serde(default)]
pub disposition: Option<Disposition>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoveResponse {
pub did: String,
pub disposition: String,
pub removed: bool,
}
const REASON_MAX: usize = 1024;
pub async fn self_remove(
auth: AuthClaims,
State(state): State<AppState>,
body: Option<Json<RemoveBody>>,
) -> Result<(StatusCode, Json<RemoveResponse>), AppError> {
let body = body.map(|Json(b)| b).unwrap_or_default();
let target_did = auth.did.clone();
let outcome = remove_inner(
&state,
&auth.did,
&target_did,
body.disposition,
String::new(),
)
.await?;
Ok((StatusCode::OK, Json(outcome)))
}
pub async fn admin_remove(
admin: AdminAuth,
State(state): State<AppState>,
Path(target_did): Path<String>,
body: Option<Json<RemoveBody>>,
) -> Result<(StatusCode, Json<RemoveResponse>), AppError> {
if admin.0.did == target_did {
return Err(AppError::Validation(
"use DELETE /v1/members/me to remove yourself — \
DELETE /v1/members/{did} is for admins removing other members"
.to_string(),
));
}
let body = body.map(|Json(b)| b).unwrap_or_default();
let reason = body.reason.unwrap_or_default();
if reason.len() > REASON_MAX {
return Err(AppError::Validation(format!(
"reason exceeds {REASON_MAX} chars (got {})",
reason.len(),
)));
}
let outcome = remove_inner(&state, &admin.0.did, &target_did, body.disposition, reason).await?;
Ok((StatusCode::OK, Json(outcome)))
}
pub async fn remove_inner(
state: &AppState,
actor_did: &str,
target_did: &str,
disposition: Option<Disposition>,
reason: String,
) -> Result<RemoveResponse, AppError> {
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let target_acl = get_acl_entry(&state.acl_ks, target_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("member not found: {target_did}")))?;
let target_member = get_member(&state.members_ks, target_did).await?;
let facts = assemble_leave_facts(
state,
actor_did,
target_did,
&target_acl.role.to_string(),
target_member.as_ref(),
disposition,
&reason,
)
.await?;
let verified = VerifiedFacts::assemble(facts)?;
let policy = load_active_compiled(
&state.active_policies_ks,
&state.policies_ks,
PolicyPurpose::Removal,
)
.await?;
let allow = match crate::ceremony::decide(&verified, &policy)? {
Verdict::Allow(a) => a,
Verdict::Deny(d) => {
return Err(AppError::Forbidden(format!(
"removal denied by policy ({})",
d.code
)));
}
Verdict::Refer(_) | Verdict::RequestMore(_) => {
return Err(AppError::Internal(
"removal policy returned a non-terminal verdict; leave is synchronous".into(),
));
}
};
let initial = disposition
.or_else(|| target_member.as_ref().map(|m| m.departure_preference))
.unwrap_or(Disposition::PolicyDefault);
let resolved = match initial {
Disposition::PolicyDefault => allow
.disposition
.as_deref()
.and_then(parse_disposition_opt)
.unwrap_or(Disposition::Tombstone),
other => other,
};
let plan = EffectPlan::Depart {
subject: target_did.to_string(),
disposition: Some(disposition_wire(resolved).to_string()),
};
let EffectOutcome::Departed(outcome) = execute::apply(state, plan, actor_did).await? else {
return Err(AppError::Internal(
"depart effect did not produce a departure outcome".into(),
));
};
let disposition_str = disposition_wire(outcome.disposition);
audit_writer
.write(
actor_did,
Some(target_did),
AuditEvent::MemberRemoved(MemberRemovedData {
disposition: disposition_str.into(),
reason: reason.clone(),
}),
)
.await?;
if let Some(slot) = outcome.revoked_slot {
audit_writer
.write(
actor_did,
Some(target_did),
AuditEvent::StatusListFlipped(StatusListFlippedData {
purpose: StatusPurpose::Revocation.to_string(),
index: slot,
revoked: true,
}),
)
.await?;
}
info!(
actor = actor_did,
target = target_did,
disposition = disposition_str,
reason_present = !reason.is_empty(),
"member removed"
);
Ok(RemoveResponse {
did: target_did.to_string(),
disposition: disposition_str.into(),
removed: true,
})
}
fn disposition_wire(d: Disposition) -> &'static str {
match d {
Disposition::Purge => "purge",
Disposition::Tombstone => "tombstone",
Disposition::Historical => "historical",
Disposition::PolicyDefault => "policydefault",
}
}
async fn assemble_leave_facts(
state: &AppState,
actor_did: &str,
subject_did: &str,
subject_role: &str,
subject_member: Option<&crate::members::Member>,
disposition: Option<Disposition>,
reason: &str,
) -> Result<Facts, AppError> {
let actor_role = get_acl_entry(&state.acl_ks, actor_did)
.await?
.map(|e| e.role.to_string());
let subject_member_state = Some(MemberState {
role: subject_role.to_string(),
status: subject_member
.map(|m| {
if m.removed_at.is_some() {
"removed"
} else {
"active"
}
})
.unwrap_or("active")
.to_string(),
joined_at: subject_member.map(|m| m.joined_at).unwrap_or_else(Utc::now),
personhood: None,
});
let community_did = load_profile(&state.community_ks)
.await?
.map(|p| p.community_did)
.unwrap_or_default();
let member_count = list_members(&state.members_ks).await?.len() as u64;
let request = if disposition.is_some() || !reason.is_empty() {
let mut m = serde_json::Map::new();
if let Some(d) = disposition {
m.insert("disposition".into(), json!(disposition_wire(d)));
}
if !reason.is_empty() {
m.insert("reason".into(), json!(reason));
}
Some(serde_json::Value::Object(m))
} else {
None
};
Ok(Facts {
purpose: Purpose::Leave,
now: Utc::now(),
actor: Actor {
did: actor_did.to_string(),
role: actor_role,
authenticated: true,
},
subject: Subject {
did: subject_did.to_string(),
},
context: Context {
community_did,
channel: "rest".to_string(),
member_count,
},
evidence: Evidence {
invitation: None,
presentation: None,
request,
},
state: FactsState {
subject_member: subject_member_state,
},
})
}
fn parse_disposition_opt(s: &str) -> Option<Disposition> {
match s {
"purge" => Some(Disposition::Purge),
"tombstone" => Some(Disposition::Tombstone),
"historical" => Some(Disposition::Historical),
_ => None,
}
}