use serde::Deserialize;
use serde_json::Value as JsonValue;
use vti_common::audit::{AuditEvent, FieldChange, MemberUpdatedData, RoleChangedData};
use crate::acl::{VtcAclEntry, VtcRole, get_acl_entry};
use crate::auth::session::now_epoch;
use crate::error::{AppError, TaskError};
pub const UPDATE_ERR_NOT_FOUND: &str =
trust_tasks_rs::specs::vtc::members::update::v0_1::error_codes::NOT_FOUND.code;
pub const UPDATE_ERR_ADMIN_ROLE_FORBIDDEN: &str =
trust_tasks_rs::specs::vtc::members::update::v0_1::error_codes::ADMIN_ROLE_FORBIDDEN.code;
pub const UPDATE_ERR_CONSENT_GRANT_FORBIDDEN: &str =
trust_tasks_rs::specs::vtc::members::update::v0_1::error_codes::CONSENT_GRANT_FORBIDDEN.code;
use crate::members::{Disposition, Member, get_member, store_member};
use crate::routes::members::read::{MemberEnvelope, MemberResponse};
use crate::server::AppState;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct UpdateMemberRequest {
pub role: Option<VtcRole>,
pub label: Option<String>,
pub publish_consent: Option<bool>,
pub departure_preference: Option<Disposition>,
pub extensions: Option<JsonValue>,
}
const FORGES_EXTENSION: &str = "forges";
pub(crate) async fn update_member_inner(
state: &AppState,
auth: &vti_common::auth::extractor::AuthClaims,
did: &str,
req: UpdateMemberRequest,
) -> Result<MemberEnvelope, TaskError> {
vti_common::identifier::validate_did("did", did)?;
if matches!(req.role, Some(VtcRole::Admin)) {
return Err(TaskError::declared(
UPDATE_ERR_ADMIN_ROLE_FORBIDDEN,
AppError::Validation(format!(
"`role: admin` is not a metadata update; promote {did} with the signed \
acl/change-role {{\"fromRole\": \"<current role>\", \"toRole\": \"admin\"}}, \
which requires a passkey gesture"
)),
));
}
if let Some(next) = &req.role {
let held = get_acl_entry(&state.acl_ks, did)
.await?
.is_some_and(|e| e.is_administrator());
if next.implied_admin_role().is_some() || held {
return Err(TaskError::declared(
UPDATE_ERR_ADMIN_ROLE_FORBIDDEN,
AppError::Validation(format!(
"moving {did} to `{next}` changes administrative authority, which is not a \
metadata update; use the signed acl/change-role {{\"fromRole\": \"<current \
role>\", \"toRole\": \"{next}\"}}, which takes a passkey gesture bound to it"
)),
));
}
}
let self_edit = auth.did == did;
if self_edit && req.role.is_some() {
return Err(crate::routes::acl::own_role_refusal(
crate::acl::admin_consent::single_admin_mode(state).await,
)
.into());
}
let audit_writer = state
.audit_writer
.as_ref()
.ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;
let not_found = || {
TaskError::declared(
UPDATE_ERR_NOT_FOUND,
AppError::NotFound(format!("member not found: {did}")),
)
};
let acl = get_acl_entry(&state.acl_ks, did)
.await?
.ok_or_else(not_found)?;
let edit_guard = crate::members::storage::edit_lock().await;
let mut member = get_member(&state.members_ks, did)
.await?
.ok_or_else(not_found)?;
if req.publish_consent == Some(true) && !member.publish_consent {
return Err(TaskError::declared(
UPDATE_ERR_CONSENT_GRANT_FORBIDDEN,
AppError::Validation(format!(
"{did} has not consented to trust-registry publication, and that consent is \
theirs to give, not an administrator's: an administrator may withdraw it \
(`publishConsent: false`) but never grant it. The member gives it with \
`registryConsent: true` on vtc/join-requests/submit"
)),
));
}
let mut fields_changed: Vec<String> = Vec::new();
let mut changes: Vec<FieldChange> = Vec::new();
if let Some(consent) = req.publish_consent
&& consent != member.publish_consent
{
changes.push(FieldChange {
field: "publishConsent".into(),
old: Some(JsonValue::Bool(member.publish_consent)),
new: Some(JsonValue::Bool(consent)),
});
member.publish_consent = consent;
fields_changed.push("publishConsent".into());
}
if let Some(pref) = req.departure_preference
&& pref != member.departure_preference
{
changes.push(FieldChange {
field: "departurePreference".into(),
old: serde_json::to_value(member.departure_preference).ok(),
new: serde_json::to_value(pref).ok(),
});
member.departure_preference = pref;
fields_changed.push("departurePreference".into());
}
let extensions = match req.extensions {
Some(ext) if ext.get(FORGES_EXTENSION).is_some() => {
return Err(AppError::Validation(format!(
"`extensions.{FORGES_EXTENSION}` holds the member's linked forge accounts and is \
written only by git-ns/account/link; leave it out"
))
.into());
}
Some(mut ext) => {
if let Some(forges) = member.extensions.get(FORGES_EXTENSION).cloned() {
if ext.is_null() {
ext = serde_json::json!({});
}
let Some(o) = ext.as_object_mut() else {
return Err(AppError::Validation(
"`extensions` must be an object while the member has linked forge \
accounts, which it keeps"
.into(),
)
.into());
};
o.insert(FORGES_EXTENSION.into(), forges);
}
Some(ext)
}
None => None,
};
if let Some(extensions) = extensions
&& extensions != member.extensions
{
changes.push(FieldChange {
field: "extensions".into(),
old: Some(member.extensions.clone()),
new: Some(extensions.clone()),
});
member.extensions = extensions;
fields_changed.push("extensions".into());
}
if !fields_changed.is_empty() {
store_member(&state.members_ks, &member).await?;
}
drop(edit_guard);
if let Some(ref requested) = req.label {
let trimmed = requested.trim();
let new_label = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
if new_label != acl.label {
changes.push(FieldChange {
field: "label".into(),
old: acl.label.clone().map(JsonValue::String),
new: new_label.clone().map(JsonValue::String),
});
let mut updated = acl.clone();
let self_set = self_edit && new_label.is_some();
if self_set || acl.label_set_by_subject {
changes.push(FieldChange {
field: crate::routes::acl::LABEL_SET_BY_SUBJECT.into(),
old: Some(JsonValue::Bool(acl.label_set_by_subject)),
new: Some(JsonValue::Bool(self_set)),
});
fields_changed.push(crate::routes::acl::LABEL_SET_BY_SUBJECT.into());
}
updated.label = new_label;
updated.label_set_by_subject = self_set;
updated.updated_at = Some(now_epoch());
updated.updated_by = Some(auth.did.clone());
crate::acl::store_acl_entry(&state.acl_ks, &updated).await?;
fields_changed.push("label".into());
}
}
let role_change = match req.role {
Some(new_role) if new_role != acl.role => Some(new_role),
_ => None,
};
if let Some(new_role) = role_change {
let granted = crate::ceremony::role_change_via_pipeline(
state,
auth,
did,
&acl.role.to_string(),
&new_role.to_string(),
None,
)
.await?;
audit_writer
.write(
&auth.did,
Some(did),
AuditEvent::RoleChanged(RoleChangedData {
previous_role: granted.previous_role,
new_role: granted.new_role,
}),
)
.await?;
}
if !fields_changed.is_empty() {
audit_writer
.write(
&auth.did,
Some(did),
AuditEvent::MemberUpdated(MemberUpdatedData {
fields_changed: fields_changed.clone(),
changes,
}),
)
.await?;
}
let acl = get_acl_entry(&state.acl_ks, did)
.await?
.ok_or_else(|| AppError::NotFound(format!("member not found: {did}")))?;
let member = get_member(&state.members_ks, did)
.await?
.ok_or_else(|| AppError::NotFound(format!("member not found: {did}")))?;
Ok(MemberEnvelope {
member: MemberResponse::from_pair_for_route(acl, member),
})
}
impl MemberResponse {
pub(crate) fn from_pair_for_route(acl: VtcAclEntry, member: Member) -> Self {
Self {
did: member.did,
role: acl.role,
label: acl.label,
joined_at: member.joined_at,
publish_consent: member.publish_consent,
departure_preference: member.departure_preference,
status_list_index: member.status_list_index,
current_vmc_id: member.current_vmc_id,
current_role_vac_id: member.current_role_vac_id,
extensions: member.extensions,
personhood: member.personhood,
personhood_asserted_at: member.personhood_asserted_at,
joined_via_invitation: member.joined_via_invitation,
member_vmc_id: member.member_vmc_id,
member_vmc_received_at: member.member_vmc_received_at,
}
}
}