use std::sync::LazyLock;
use affinidi_status_list::StatusPurpose;
use affinidi_vc::VerifiableCredential;
use tokio::sync::Mutex;
use tracing::warn;
use uuid::Uuid;
use vti_common::error::AppError;
use super::effects::EffectPlan;
use crate::acl::{
VtcAclEntry, VtcRole, delete_acl_entry, get_acl_entry, list_acl_entries, store_acl_entry,
};
use crate::auth::session::now_epoch;
use crate::credentials::{
CredentialStatusRef, RoleVecParams, VmcParams, build_role_vec, build_vmc,
};
use crate::members::{Disposition, Member, delete_member, get_member, store_member};
use crate::server::AppState;
use crate::status_list;
static LAST_ADMIN_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[derive(Debug)]
pub enum EffectOutcome {
Admitted(Box<AdmitOutcome>),
Departed(DepartOutcome),
Reminted(Box<RemintOutcome>),
None,
}
#[derive(Debug)]
pub struct AdmitOutcome {
pub vmc: VerifiableCredential,
pub role_vec: VerifiableCredential,
pub status_list_index: u32,
}
#[derive(Debug)]
pub struct RemintOutcome {
pub previous_role: VtcRole,
pub role_vec: VerifiableCredential,
}
#[derive(Debug)]
pub struct DepartOutcome {
pub disposition: Disposition,
pub revoked_slot: Option<u32>,
}
pub async fn apply(
state: &AppState,
plan: EffectPlan,
actor_did: &str,
) -> Result<EffectOutcome, AppError> {
match plan {
EffectPlan::Admit {
subject,
role,
obligations: _,
} => {
let role = parse_role(&role)?;
let outcome = admit(state, &subject, role, actor_did).await?;
Ok(EffectOutcome::Admitted(Box::new(outcome)))
}
EffectPlan::Depart {
subject,
disposition,
} => {
let disposition = parse_disposition(disposition.as_deref());
let outcome = depart(state, &subject, disposition, actor_did).await?;
Ok(EffectOutcome::Departed(outcome))
}
EffectPlan::Remint { subject, role } => {
let role = parse_role(&role)?;
let outcome = remint(state, &subject, role).await?;
Ok(EffectOutcome::Reminted(Box::new(outcome)))
}
EffectPlan::NoStateChange => Ok(EffectOutcome::None),
EffectPlan::Project { .. } => Err(AppError::Internal(
"directory projection is applied by the route, not the effect executor".into(),
)),
}
}
fn parse_role(role: &str) -> Result<VtcRole, AppError> {
role.parse::<VtcRole>()
.map_err(|_| AppError::Validation(format!("effect plan carries an unknown role: {role}")))
}
async fn admit(
state: &AppState,
subject_did: &str,
role: VtcRole,
actor_did: &str,
) -> Result<AdmitOutcome, AppError> {
if get_acl_entry(&state.acl_ks, subject_did).await?.is_some() {
return Err(AppError::Conflict(format!(
"{subject_did} already has an ACL row; refusing to admit a duplicate membership"
)));
}
let acl = VtcAclEntry {
did: subject_did.to_string(),
role: role.clone(),
label: None,
allowed_contexts: vec![],
created_at: now_epoch(),
created_by: actor_did.to_string(),
expires_at: None,
};
store_acl_entry(&state.acl_ks, &acl).await?;
let mut member = Member::fresh(subject_did);
store_member(&state.members_ks, &member).await?;
let (vmc, role_vec, status_list_index) =
issue_member_credentials(state, subject_did, role).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?;
Ok(AdmitOutcome {
vmc,
role_vec,
status_list_index,
})
}
async fn issue_member_credentials(
state: &AppState,
subject_did: &str,
role: VtcRole,
) -> 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(subject_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(subject_did, role).with_id(vec_id),
)
.await?;
let to_value = |vc| {
serde_json::to_value(vc)
.map_err(|e| AppError::Internal(format!("credential -> value: {e}")))
};
crate::schemas::validate_issued(&state.schemas_ks, &to_value(&vmc)?).await?;
crate::schemas::validate_issued(&state.schemas_ks, &to_value(&role_vec)?).await?;
status_list::store_state(&state.status_lists_ks, &row).await?;
status_list::maybe_emit_occupancy_warning(&row);
Ok((vmc, role_vec, slot))
}
async fn remint(
state: &AppState,
subject_did: &str,
new_role: VtcRole,
) -> Result<RemintOutcome, AppError> {
let _guard = LAST_ADMIN_LOCK.lock().await;
let mut acl = get_acl_entry(&state.acl_ks, subject_did)
.await?
.ok_or_else(|| AppError::NotFound(format!("member not found: {subject_did}")))?;
let previous_role = acl.role.clone();
if matches!(previous_role, VtcRole::Admin) && !matches!(new_role, VtcRole::Admin) {
let other_admins = list_acl_entries(&state.acl_ks)
.await?
.iter()
.filter(|e| e.did != subject_did && matches!(e.role, VtcRole::Admin))
.count();
if other_admins == 0 {
return Err(AppError::Conflict(format!(
"refusing to demote the last admin ({subject_did}) — promote another \
member to admin first"
)));
}
}
acl.role = new_role.clone();
store_acl_entry(&state.acl_ks, &acl).await?;
let role_vec = issue_role_vec(state, subject_did, new_role).await?;
if let Some(mut member) = get_member(&state.members_ks, subject_did).await? {
member.current_role_vec_id = top_level_id(&role_vec);
store_member(&state.members_ks, &member).await?;
}
Ok(RemintOutcome {
previous_role,
role_vec,
})
}
async fn issue_role_vec(
state: &AppState,
subject_did: &str,
role: VtcRole,
) -> Result<VerifiableCredential, AppError> {
let signer = state.credential_signer.as_ref().ok_or_else(|| {
AppError::Internal(
"credential signer not initialised — cannot re-mint role VEC (run setup first)".into(),
)
})?;
let vec_id = format!("urn:uuid:{}", Uuid::new_v4());
build_role_vec(
signer,
RoleVecParams::new(subject_did, role).with_id(vec_id),
)
.await
}
fn parse_disposition(disposition: Option<&str>) -> Disposition {
match disposition {
Some("purge") => Disposition::Purge,
Some("historical") => Disposition::Historical,
_ => Disposition::Tombstone,
}
}
async fn depart(
state: &AppState,
subject_did: &str,
disposition: Disposition,
_actor_did: &str,
) -> Result<DepartOutcome, AppError> {
let _guard = LAST_ADMIN_LOCK.lock().await;
if let Some(acl) = get_acl_entry(&state.acl_ks, subject_did).await?
&& matches!(acl.role, VtcRole::Admin)
{
let other_admins = list_acl_entries(&state.acl_ks)
.await?
.iter()
.filter(|e| e.did != subject_did && matches!(e.role, VtcRole::Admin))
.count();
if other_admins == 0 {
return Err(AppError::Conflict(format!(
"refusing to remove the last admin ({subject_did}) — promote another \
member to admin first"
)));
}
}
let member = get_member(&state.members_ks, subject_did).await?;
let slot = member.as_ref().and_then(|m| m.status_list_index);
delete_acl_entry(&state.acl_ks, subject_did).await?;
match (disposition, member) {
(Disposition::Purge, _) => {
delete_member(&state.members_ks, subject_did).await?;
}
(Disposition::Tombstone, Some(mut m)) => {
m.tombstone();
store_member(&state.members_ks, &m).await?;
}
(Disposition::Historical, Some(mut m)) => {
m.mark_historical();
store_member(&state.members_ks, &m).await?;
}
(Disposition::Tombstone | Disposition::Historical, None) => {}
(Disposition::PolicyDefault, _) => {
unreachable!("disposition must be concrete before depart");
}
}
let revoked_slot = match slot {
Some(slot) => match flip_revocation(state, slot).await {
Ok(()) => Some(slot),
Err(e) => {
warn!(
error = %e,
slot,
target = subject_did,
"failed to flip revocation bit on departure — ACL/Member already \
removed; operator must reflip manually"
);
None
}
},
None => None,
};
Ok(DepartOutcome {
disposition,
revoked_slot,
})
}
async fn flip_revocation(state: &AppState, slot: u32) -> Result<(), AppError> {
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(),
)
})?;
status_list::flip(&mut row, slot, true)
.map_err(|e| AppError::Internal(format!("flip revocation slot {slot}: {e}")))?;
status_list::store_state(&state.status_lists_ks, &row).await?;
status_list::maybe_emit_occupancy_warning(&row);
Ok(())
}
pub(crate) 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)))
}