vtc-service 0.12.0

Service for Verifiable Trust Communities
//! `PATCH /v1/members/{did}` — M1.5.1.
//!
//! Non-role fields (publish consent, departure preference, extensions)
//! are written directly. Publish consent only goes **down** here: an
//! administrator may withdraw a member's trust-registry consent but never
//! grant it ([`UPDATE_ERR_CONSENT_GRANT_FORBIDDEN`]). A **role change** is the role-change ceremony:
//! it runs through the decision pipeline ([`crate::ceremony`]) —
//! assemble Facts → decide the active `roleChange` policy → apply via
//! the `Remint` executor arm (which updates the ACL role in place,
//! re-mints the role VAC, and enforces no-last-admin on demotion).
//!
//! `role = admin` is **refused here**, with the `adminRoleForbidden` code
//! `vtc/members/update/0.1` declares for it: promotion to admin is a separate,
//! gated flow, not a metadata update. `acl/change-role/0.1` is the task defined
//! for role transitions, and the refusal names it.
//!
//! ## Why it moved (#1645)
//!
//! Promotion landed here when the fused
//! `POST /v1/members/{did}/promote-to-admin/{start,finish}` endpoint was
//! retired, with the step-up it carried re-expressed as an in-handler
//! `require_fresh_step_up`. That was sound as far as this route went, and it
//! did not go far enough: `acl/change-role` and `acl/grant` assign the same
//! `admin` role to the same ACL row and asked for nothing at all, so an admin
//! session could confer admin with no second factor simply by using a
//! different door. The gate now sits on the transition rather than on the
//! route — as a host invariant in the role-change ceremony, which no policy
//! edit can disable — and this route refuses the field outright.
//!
//! Role changes that carry no administrative authority — between `member` and
//! custom community roles — are still made here, and still run the role-change
//! ceremony. Since role-based administration (`vtc-admin-roles.md`,
//! VTI-ACL-030) `moderator` and `issuer` imply administrative roles, so a move
//! to or from any role that does is refused here with the same code and sent
//! to `acl/change-role`, whose passkey gesture is bound to the move.

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};

/// `vtc/members/update:notFound` — no member with that DID.
pub const UPDATE_ERR_NOT_FOUND: &str =
    trust_tasks_rs::specs::vtc::members::update::v0_1::error_codes::NOT_FOUND.code;
/// `vtc/members/update:adminRoleForbidden` — `role` was `admin`.
pub const UPDATE_ERR_ADMIN_ROLE_FORBIDDEN: &str =
    trust_tasks_rs::specs::vtc::members::update::v0_1::error_codes::ADMIN_ROLE_FORBIDDEN.code;
/// `vtc/members/update:consentGrantForbidden` — `publishConsent: true` for a
/// member whose consent is `false`.
///
/// Consent to trust-registry publication is the member's own decision: they
/// give it with `registryConsent` on `vtc/join-requests/submit` (and again on a
/// rejoin, which writes a fresh member row). An administrator may **withdraw**
/// it — a takedown, which the registry syncer turns into a delete of the
/// member's record — but never grant it, or an administrator could publish
/// someone who said no. Declared by the specification (trust-tasks-tf #789).
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;

/// Body of the PATCH request. Every field is optional; a request
/// with no fields is a no-op (200 with the current row).
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct UpdateMemberRequest {
    pub role: Option<VtcRole>,
    /// Human-readable name for this member, shown wherever their DID is
    /// rendered (admin UI, `vtc acl list`).
    ///
    /// The label lives on the ACL row, so until now it was writable only via
    /// `acl/grant` — a whole re-grant to correct a typo in a display name.
    /// An empty string clears it; omitting the field leaves it unchanged.
    pub label: Option<String>,
    /// Trust-registry publication consent. Only `false` changes anything: an
    /// administrator may withdraw a member's consent, never grant it —
    /// `true` for a member who has not consented is refused with
    /// `vtc/members/update:consentGrantForbidden`.
    pub publish_consent: Option<bool>,
    pub departure_preference: Option<Disposition>,
    pub extensions: Option<JsonValue>,
}

/// Apply one `vtc/members/update/0.1` on behalf of `auth` — the whole of the
/// operation, with no transport in it.
///
/// The signed-document arm in [`crate::trust_tasks`] (#1641 phase 2) calls
/// this, on every transport. The signed path synthesises
/// `auth` from the verified signer's **ACL entry**, which is why nothing here
/// may read the caller's session: there is none. The one thing that would —
/// the promotion step-up in [`crate::ceremony::role_change_via_pipeline`] — is
/// unreachable, because `role: admin` is refused below before any of this runs,
/// and it fails closed (a session-less claim has no elevation) if that ever
/// changes.
/// The member extension `git-ns/account/link` owns.
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)?;

    // Declared, and refused before anything is read or written: the
    // specification's consumer conformance for this task is "if `role` is
    // `admin`, return `adminRoleForbidden` and change nothing". The message
    // names the replacement so an operator is not left to find it.
    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"
            )),
        ));
    }

    // A role that implies an administrative role (`moderator` → moderator,
    // `issuer` → credential-officer), or a move away from one, changes
    // administrative authority (VTI-ACL-030): that takes a passkey gesture
    // bound to the operation, which only `acl/change-role` carries. This door
    // moves community roles that carry no administrative authority.
    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"
                )),
            ));
        }
    }

    // The role and the label live on the ACL row, and a subject may not modify
    // its own entry (VTI-ACL-052) — on this door any more than on
    // `acl/change-role` — except its label, which confers no authority (item
    // 2): that is written below, marked self-set and audited as such. The
    // member-row fields (publish consent, departure preference, extensions)
    // are not authority and stay editable.
    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)?;
    // Held across the read and the write of the member row, so a concurrent
    // edit of another field (a forge-account link, say) is not lost to this
    // whole-row write. Released before the role ceremony below, which does its
    // own writes.
    let edit_guard = crate::members::storage::edit_lock().await;
    let mut member = get_member(&state.members_ks, did)
        .await?
        .ok_or_else(not_found)?;

    // Registry consent only goes down from here. Refused before anything is
    // written, so a request that also carries other fields changes nothing.
    // Setting it to what it already is (either value) is a no-op, not a grant.
    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"
            )),
        ));
    }

    // Non-role field updates — written directly (not a ceremony).
    // Persisted *before* any role change so the Remint executor (which
    // re-reads the member to repoint its role VAC) sees them.
    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(),
            // (Disposition is Copy; values captured before the move below.)
        });
        member.departure_preference = pref;
        fields_changed.push("departurePreference".into());
    }
    // `extensions.forges` is the record of the forge accounts a member linked
    // with `git-ns/account/link`, and only that task (and a departure) writes
    // it: an administrator who could set it could hand a member any forge
    // account — past the one-account-per-member rule and every check the
    // bridge's `accountLinked` passes — and the bridge would then give that
    // account the member's forge role. A replacement keeps whatever is linked.
    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);

    // The label lives on the ACL row, not the member row. Written before any
    // role change so the ceremony (which re-reads the ACL entry) picks it up
    // rather than overwriting it from a stale copy.
    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();
            // Set by the subject: marked self-set, wherever it is shown to
            // another party; set by anyone else: the mark goes (VTI-ACL-052
            // item 2).
            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());
        }
    }

    // Role change → the role-change ceremony.
    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 {
        // `admin` was refused at the top, so this is always a lateral move or
        // a demotion — the serialisation and the elevation gate the promotion
        // path needs are the ceremony's, not this handler's.
        let granted = crate::ceremony::role_change_via_pipeline(
            state,
            auth,
            did,
            &acl.role.to_string(),
            &new_role.to_string(),
            // Never a promotion, so never a consent to bind.
            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?;
    }

    // Re-read the authoritative state for the response — the Remint
    // executor may have changed the ACL role + the member's role-VAC
    // pointer.
    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}")))?;

    // `{member: …}` — the shape `vtc/members/update/0.1` publishes, same as
    // its `show` sibling. The row was returned bare until #1094.
    Ok(MemberEnvelope {
        member: MemberResponse::from_pair_for_route(acl, member),
    })
}

// Re-export `from_pair` under a route-only alias so this module
// doesn't have to make the constructor public on `MemberResponse`.
impl MemberResponse {
    pub(crate) fn from_pair_for_route(acl: VtcAclEntry, member: Member) -> Self {
        // Inline the same join the read endpoints do — duplicating
        // the body (~10 lines) is cheaper than exposing a public
        // constructor that's only used by route handlers.
        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,
        }
    }
}