vtc-service 0.12.0

Service for Verifiable Trust Communities
//! `POST /v1/join-requests/{id}/decide` — the admin decision endpoint
//! (`vtc/join-requests/decide/0.1`).
//!
//! One payload decides a pending request either way:
//! `{ decision: approved | rejected, reason? }`. Supersedes the former
//! `/approve` + `/reject` pair (near-identical payloads, same admin gate,
//! same Pending-state check, same proof posture) — the decision is one
//! enum field, not two tasks.
//!
//! Approve atomically writes the ACL row (`VtcRole::Member`),
//! the Member record, and the audit envelopes
//! (`JoinRequestApproved` + `MemberAdded`). The applicant_did is
//! already validated at submit time so the only failure modes
//! here are auth + duplicate-membership. Reject flips the status and
//! records the operator's reason.

use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use tracing::{info, warn};
use uuid::Uuid;

use vta_sdk::protocols::join_requests::ADMIN_REJECT_CODE;
use vti_common::audit::{AuditEvent, JoinRequestData, JoinRequestRejectedData};
use vti_common::error::AppError;

use crate::acl::VtcRole;
use crate::ceremony::execute;
use crate::ceremony::{EffectOutcome, EffectPlan};
use crate::error::TaskError;
use crate::join::{JoinDecision, JoinRequest, JoinStatus, get_join_request, store_join_request};
use crate::server::AppState;

const REJECT_REASON_MAX: usize = 1024;

use trust_tasks_rs::specs::vtc::join_requests::decide::v0_1::error_codes as decide_codes;

/// `vtc/join-requests/decide:notFound` — no join request with that id.
pub const DECIDE_ERR_NOT_FOUND: &str = decide_codes::NOT_FOUND.code;
/// `vtc/join-requests/decide:notPending` — the request is not pending.
pub const DECIDE_ERR_NOT_PENDING: &str = decide_codes::NOT_PENDING.code;

/// The two ways a pending request can be decided
/// (`vtc/join-requests/decide/0.1`'s `decision` enum).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub enum Decision {
    /// Admit the applicant as a member (issues the VMC + role VAC).
    Approved,
    /// Refuse the applicant; recoverable only by re-applying.
    Rejected,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct DecideBody {
    pub decision: Decision,
    /// Optional operator rationale, recorded in the audit trail.
    /// Chiefly useful with `rejected`.
    #[serde(default)]
    pub reason: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct DecideResponse {
    pub request_id: Uuid,
    pub status: String,
    /// Issued VMC (M2.12). Also pushed to the applicant's wallet over
    /// DIDComm on approve (best-effort); kept inline so the admin caller
    /// can still hand it over out-of-band if that delivery doesn't land.
    /// `None` on the reject path.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub vmc: Option<JsonValue>,
    /// Issued role VAC. Same delivery story as `vmc`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub role_vac: Option<JsonValue>,
}

/// Decide a pending join request on behalf of `actor_did` — 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 reason cap, the pending-state gate, the
/// credential issuance and the audit trail are decided here.
///
/// `transport` is recorded on the `JoinRequestApproved` envelope, so the audit
/// row says which door the decision came through rather than claiming `rest`
/// for a document that arrived over DIDComm or TSP.
///
/// # The ordering this operation depends on
///
/// Approving issues a membership credential and a role endorsement, so a
/// decision executed twice issues two of each. Nothing here guards that: the
/// caller does, by claiming the document's `id` **before** this runs and
/// settling it after (`dispatch_trust_task_core`, §6a of
/// `docs/05-design-notes/vtc-trust-task-proof-enforcement.md`). The
/// pending-state gate is a second line only — it refuses the *re-decision* of
/// an already-decided request, which is not the same thing as answering a
/// redelivery with what the first execution returned.
pub(crate) async fn decide_inner(
    state: &AppState,
    actor_did: &str,
    transport: &str,
    id: Uuid,
    body: DecideBody,
) -> Result<DecideResponse, TaskError> {
    let reason = body.reason.unwrap_or_default();
    if reason.len() > REJECT_REASON_MAX {
        return Err(AppError::Validation(format!(
            "decision reason exceeds {REJECT_REASON_MAX} chars (got {})",
            reason.len(),
        ))
        .into());
    }

    // Shared pending-state gate — the one lifecycle check both outcomes had.
    // Both refusals are the codes `vtc/join-requests/decide/0.1` declares, so
    // an operator's client can tell "no such request" from "already decided"
    // without reading the prose (#1600). Status and message are unchanged.
    let req = get_join_request(&state.join_requests_ks, id)
        .await?
        .ok_or_else(|| {
            TaskError::declared(
                DECIDE_ERR_NOT_FOUND,
                AppError::NotFound(format!("join request not found: {id}")),
            )
        })?;
    if req.status != JoinStatus::Pending {
        return Err(TaskError::declared(
            DECIDE_ERR_NOT_PENDING,
            AppError::Conflict(format!(
                "join request {id} is {:?}, not Pending",
                req.status
            )),
        ));
    }

    let response = match body.decision {
        Decision::Approved => approve_pending(state, actor_did, transport, id, req).await?,
        Decision::Rejected => reject_pending(state, actor_did, id, req, reason).await?,
    };
    // The join review in the action list shows the same state as this page:
    // its item closes now, naming who decided, whichever door they used
    // (`vtc-action-list.md` §8.2).
    crate::admin_actions::queues::join_decided(
        state,
        id,
        body.decision == Decision::Approved,
        actor_did,
    )
    .await;
    Ok(response)
}

/// The approve arm: admit the applicant + issue credentials.
async fn approve_pending(
    state: &AppState,
    actor_did: &str,
    transport: &str,
    id: Uuid,
    mut req: JoinRequest,
) -> Result<DecideResponse, AppError> {
    let audit_writer = state
        .audit_writer
        .as_ref()
        .ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;

    // Effects: admit the applicant as a member. The ceremony effect
    // executor owns the duplicate-ACL guard, the ACL + Member writes,
    // and credential issuance (it is the single state-mutating seam —
    // see `ceremony::execute`). Approve wraps it with the join-request
    // status flip + audit. A duplicate ACL surfaces as the executor's
    // `Conflict` → 409, same as before.
    let plan = EffectPlan::Admit {
        subject: req.applicant_did.clone(),
        role: VtcRole::Member.to_string(),
        obligations: vec![],
        // The applicant's opt-in to registry publication, as submitted. An
        // operator's approval admits the applicant; it does not consent for them.
        publish_consent: req.registry_consent,
        label: None,
    };
    let EffectOutcome::Admitted(creds) = execute::apply(state, plan, actor_did).await? else {
        return Err(AppError::Internal(
            "admit effect did not produce credentials".into(),
        ));
    };

    // Deliver the issued credentials to the applicant's wallet over DIDComm. A
    // referred-then-approved applicant presented over DIDComm and is not
    // connected now, so — like the auto-admit path — push the VMC + role VAC to
    // its mediator. Best-effort: the credentials are already issued and are also
    // returned inline below for out-of-band hand-off, so a delivery failure (no
    // mediator, unreachable holder) is logged, not fatal.
    if let Err(e) = crate::credentials::delivery::deliver_membership_credentials(
        state,
        &req.applicant_did,
        &creds,
    )
    .await
    {
        warn!(
            request_id = %id,
            applicant = %req.applicant_did,
            error = %e,
            "membership-credential delivery failed on approve; credentials issued and returned inline"
        );
    }

    req.status = JoinStatus::Approved;
    store_join_request(&state.join_requests_ks, &req).await?;

    audit_writer
        .write(
            actor_did,
            Some(&req.applicant_did),
            AuditEvent::JoinRequestApproved(JoinRequestData {
                request_id: id.to_string(),
                transport: transport.to_string(),
            }),
        )
        .await?;
    // The MemberAdded + VmcIssued + VecIssued envelopes for the admit effect are
    // shared with the auto-admit path (see `super::audit`) so the two cannot
    // record divergent trails for the same effect.
    crate::join::emit_admit_audit(
        audit_writer,
        actor_did,
        &req.applicant_did,
        &creds,
        &VtcRole::Member.to_string(),
        Some(id.to_string()),
    )
    .await?;

    info!(
        request_id = %id,
        applicant = %req.applicant_did,
        admin = %actor_did,
        status_list_index = creds.status_list_index,
        "join request approved"
    );

    Ok(DecideResponse {
        request_id: id,
        status: req.status.to_string(),
        vmc: Some(
            serde_json::to_value(&creds.vmc)
                .map_err(|e| AppError::Internal(format!("serialise VMC for response: {e}")))?,
        ),
        role_vac: Some(
            serde_json::to_value(&creds.role_vac)
                .map_err(|e| AppError::Internal(format!("serialise VAC for response: {e}")))?,
        ),
    })
}

/// The reject arm: flip the status + record the operator's reason.
async fn reject_pending(
    state: &AppState,
    actor_did: &str,
    id: Uuid,
    mut req: JoinRequest,
    reason: String,
) -> Result<DecideResponse, AppError> {
    let audit_writer = state
        .audit_writer
        .as_ref()
        .ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;

    req.status = JoinStatus::Rejected;
    // Record the refusal on the row, not only in the audit log. The audit
    // trail is admin-facing; the applicant reads their own request, and
    // until this was written the operator's reason reached them through no
    // path at all - reject_pending emits no message and the poll returned
    // bare {requestId, status}. ADMIN_REJECT_CODE rather than a policy
    // code because no policy decided this: a human did, and a client that
    // can tell the two apart knows whether re-applying could ever help.
    req.decision = Some(JoinDecision {
        code: ADMIN_REJECT_CODE.to_string(),
        reason: (!reason.is_empty()).then(|| reason.clone()),
        decided_at: Utc::now(),
    });
    store_join_request(&state.join_requests_ks, &req).await?;

    audit_writer
        .write(
            actor_did,
            Some(&req.applicant_did),
            AuditEvent::JoinRequestRejected(JoinRequestRejectedData {
                request_id: id.to_string(),
                reason: reason.clone(),
                // Manual admin reject — `reason` is the operator's words;
                // there is no policy verdict to record.
                policy_decision: None,
            }),
        )
        .await?;

    info!(
        request_id = %id,
        applicant = %req.applicant_did,
        admin = %actor_did,
        reason_present = !reason.is_empty(),
        "join request rejected"
    );

    Ok(DecideResponse {
        request_id: id,
        status: req.status.to_string(),
        vmc: None,
        role_vac: None,
    })
}