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, store_acl_entry};
use crate::auth::session::now_epoch;
use crate::credentials::{
CredentialStatusRef, RoleVacParams, VmcParams, build_role_vac, 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(()));
pub(crate) async fn lock_admin_set() -> tokio::sync::MutexGuard<'static, ()> {
LAST_ADMIN_LOCK.lock().await
}
#[derive(Debug)]
pub enum EffectOutcome {
Admitted(Box<AdmitOutcome>),
Departed(DepartOutcome),
Reminted(Box<RemintOutcome>),
None,
}
#[derive(Debug)]
pub struct AdmitOutcome {
pub vmc: VerifiableCredential,
pub role_vac: VerifiableCredential,
pub status_list_index: u32,
}
#[derive(Debug)]
pub struct RemintOutcome {
pub previous_role: VtcRole,
pub role_vac: Option<VerifiableCredential>,
}
#[derive(Debug)]
pub struct DepartOutcome {
pub disposition: Disposition,
pub revoked_slot: Option<u32>,
pub revoked_grants: Vec<crate::endorsements::Endorsement>,
}
pub async fn apply(
state: &AppState,
plan: EffectPlan,
actor_did: &str,
) -> Result<EffectOutcome, AppError> {
match plan {
EffectPlan::Admit {
subject,
role,
obligations: _,
publish_consent,
label,
} => {
let role = parse_role(&role)?;
let outcome = admit(state, &subject, role, publish_consent, label, actor_did).await?;
Ok(EffectOutcome::Admitted(Box::new(outcome)))
}
EffectPlan::Enrol { subject } => {
let outcome = enrol(state, &subject).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?;
crate::admin_actions::record_effect(state).await;
Ok(EffectOutcome::Departed(outcome))
}
EffectPlan::Remint { subject, role } => {
let role = parse_role(&role)?;
let outcome = remint(state, &subject, role).await?;
crate::admin_actions::record_effect(state).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> {
let parsed = role.parse::<VtcRole>().map_err(|_| {
AppError::Validation(format!("effect plan carries an unknown role: {role}"))
})?;
parsed.refuse_unassignable()?;
Ok(parsed)
}
async fn admit(
state: &AppState,
subject_did: &str,
role: VtcRole,
publish_consent: bool,
label: Option<String>,
actor_did: &str,
) -> Result<AdmitOutcome, AppError> {
let _guard = LAST_ADMIN_LOCK.lock().await;
let _git = crate::git_ns::store::write_lock().await;
let prior_grants = match get_acl_entry(&state.acl_ks, subject_did).await? {
Some(e) if e.is_application() => e.resource_grants,
Some(_) => {
return Err(AppError::Conflict(format!(
"{subject_did} already has an ACL row; refusing to admit a duplicate membership"
)));
}
None => Vec::new(),
};
let acl = VtcAclEntry {
did: subject_did.to_string(),
role: role.clone(),
label,
admin: role.implied_authority(),
delegated_by: None,
created_at: now_epoch(),
created_by: actor_did.to_string(),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: prior_grants,
label_set_by_subject: false,
suspension: None,
};
store_acl_entry(&state.acl_ks, &acl).await?;
drop(_git);
create_membership(state, subject_did, role, publish_consent).await
}
async fn enrol(state: &AppState, subject_did: &str) -> Result<AdmitOutcome, AppError> {
let _guard = LAST_ADMIN_LOCK.lock().await;
let entry = get_acl_entry(&state.acl_ks, subject_did)
.await?
.ok_or_else(|| {
AppError::Conflict(format!(
"{subject_did} has no ACL entry to make a membership of"
))
})?;
if entry.is_application() {
return Err(AppError::Conflict(format!(
"{subject_did}'s entry is an application entry (git rights for a non-member); it \
becomes a membership through the join flow"
)));
}
if let Some(existing) = get_member(&state.members_ks, subject_did).await? {
return Err(AppError::Conflict(if existing.is_removed() {
format!("{subject_did} has a removed member record; refusing to overwrite it")
} else {
format!("{subject_did} is already a member; refusing a duplicate membership")
}));
}
create_membership(state, subject_did, entry.role, false).await
}
async fn create_membership(
state: &AppState,
subject_did: &str,
role: VtcRole,
publish_consent: bool,
) -> Result<AdmitOutcome, AppError> {
let mut member = Member::fresh(subject_did);
member.publish_consent = publish_consent;
let new_row = {
let _edit = crate::members::storage::edit_lock().await;
let new_row = get_member(&state.members_ks, subject_did).await?.is_none();
store_member(&state.members_ks, &member).await?;
new_row
};
let (vmc, role_vac, status_list_index) =
issue_member_credentials(state, subject_did, role).await?;
let vmc_value = serde_json::to_value(&vmc)
.map_err(|e| AppError::Internal(format!("serialise VMC: {e}")))?;
let role_vac_value = serde_json::to_value(&role_vac)
.map_err(|e| AppError::Internal(format!("serialise role VAC: {e}")))?;
crate::members::storage::edit_member(&state.members_ks, subject_did, |m| {
m.status_list_index = Some(status_list_index);
m.record_issued_credentials(vmc_value, role_vac_value);
true
})
.await?
.ok_or_else(|| AppError::Conflict("the member left while this was in progress".into()))?;
if new_row {
state.member_count_inc();
}
Ok(AdmitOutcome {
vmc,
role_vac,
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 _sl_guard = status_list::lock().await;
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 vac_id = format!("urn:uuid:{}", Uuid::new_v4());
let role_vac = build_role_vac(
signer,
RoleVacParams::new(subject_did, role).with_id(vac_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_vac)?).await?;
status_list::store_state(&state.status_lists_ks, &row).await?;
status_list::maybe_emit_occupancy_warning(&row);
Ok((vmc, role_vac, 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();
let now = crate::auth::session::now_epoch();
let mut next = acl.clone();
next.role = new_role.clone();
if crate::routes::acl::expressible_in_v0_1(&acl) {
next.admin = new_role.implied_authority();
}
if crate::acl::admin_consent::is_live_role_assigner(&acl, now)
&& !crate::acl::admin_consent::is_live_role_assigner(&next, now)
{
crate::acl::admin_consent::check_attrition(state, subject_did).await?;
}
acl = next;
store_acl_entry(&state.acl_ks, &acl).await?;
let role_vac = match get_member(&state.members_ks, subject_did).await? {
Some(_) => {
let role_vac = issue_role_vec(state, subject_did, new_role).await?;
let role_vac_value = serde_json::to_value(&role_vac)
.map_err(|e| AppError::Internal(format!("serialise role VAC: {e}")))?;
crate::members::storage::edit_member(&state.members_ks, subject_did, |m| {
m.record_role_vec(role_vac_value);
true
})
.await?;
Some(role_vac)
}
None => None,
};
Ok(RemintOutcome {
previous_role,
role_vac,
})
}
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 VAC (run setup first)".into(),
)
})?;
let vac_id = format!("urn:uuid:{}", Uuid::new_v4());
build_role_vac(
signer,
RoleVacParams::new(subject_did, role).with_id(vac_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;
let acl = get_acl_entry(&state.acl_ks, subject_did).await?;
if let Some(acl) = acl.as_ref()
&& crate::acl::admin_consent::is_live_role_assigner(acl, crate::auth::session::now_epoch())
{
crate::acl::admin_consent::check_attrition(state, subject_did).await?;
}
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?;
crate::rooms::departures::on_departure(
state,
subject_did,
if actor_did == subject_did {
crate::rooms::community::DepartureCause::Left
} else {
crate::rooms::community::DepartureCause::Removed
},
)
.await?;
crate::acl::delegation::clear(state, subject_did).await?;
crate::acl::delegation::on_granter_changed(state, subject_did, None).await?;
match (disposition, member) {
(Disposition::Purge, existed) => {
{
let _edit = crate::members::storage::edit_lock().await;
delete_member(&state.members_ks, subject_did).await?;
}
match crate::members::pseudonym::release_for_member(&state.members_ks, subject_did)
.await
{
Ok(0) => {}
Ok(freed) => {
tracing::info!(
subject = %subject_did,
freed,
"released personhood pseudonym claims on purge"
);
}
Err(e) => tracing::warn!(
subject = %subject_did,
error = %e,
"could not release personhood pseudonym claims — this person may be \
refused if they rejoin"
),
}
if existed.is_some() {
state.member_count_dec();
}
}
(Disposition::Tombstone, Some(_)) => {
crate::members::storage::edit_member(&state.members_ks, subject_did, |m| {
m.tombstone();
true
})
.await?;
}
(Disposition::Historical, Some(_)) => {
crate::members::storage::edit_member(&state.members_ks, subject_did, |m| {
m.mark_historical();
true
})
.await?;
}
(Disposition::Tombstone | Disposition::Historical, None) => {}
(Disposition::PolicyDefault, _) => {
unreachable!("disposition must be concrete before depart");
}
}
crate::directory::cards::on_departure(
state.directory.ks(),
subject_did,
match disposition {
Disposition::Historical => crate::directory::cards::Departure::Keep,
_ => crate::directory::cards::Departure::Erase,
},
)
.await?;
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,
};
let revoked_grants =
crate::vetting::vetters::revoke_on_departure(state, actor_did, subject_did).await;
Ok(DepartOutcome {
disposition,
revoked_slot,
revoked_grants,
})
}
async fn flip_revocation(state: &AppState, slot: u32) -> Result<(), AppError> {
status_list::with_locked(&state.status_lists_ks, StatusPurpose::Revocation, |row| {
status_list::flip(row, slot, true)
.map_err(|e| AppError::Internal(format!("flip revocation slot {slot}: {e}")))
})
.await
}
pub(crate) fn top_level_id(vc: &VerifiableCredential) -> Option<String> {
serde_json::to_value(vc)
.ok()
.as_ref()
.and_then(crate::members::top_level_id)
}