vtc-service 0.13.0

Service for Verifiable Trust Communities
//! `vtc/members/renew/0.1` — VMC + role VAC renewal (M2.13). Spec §6.3.
//! Signed document only (`trust_tasks::member_tasks`); the bearer REST route
//! it once also served (`POST /v1/members/me/renew`) had no caller once the
//! spine dispatched it (#1809) and was removed.
//!
//! The renewal flow is **unconditional on ACL membership**: no
//! expiry check, no grace window. Spec §3-F + §6.3 — the VMC
//! `validUntil` is an external-verifier concern. Inside the
//! community, the ACL is the authoritative source of "are you
//! a member?", and the ACL doesn't have an expiry on Phase 2's
//! member rows.
//!
//! On renew:
//!
//! 1. Look up the caller's ACL row (404 if removed, 401 if
//!    session unauthenticated).
//! 2. Look up the Member row to recover the existing
//!    `status_list_index` — renewal **reuses the same slot**
//!    so external chains stay coherent across the renewal
//!    boundary (spec §6.2).
//! 3. Re-evaluate `personhood.rego` per spec §6.3 step 3. The
//!    Phase 2 default ships deny-all so the new VMC always
//!    carries `personhood: false`; if the prior VMC had a
//!    different flag, the audit envelope records
//!    `personhood_changed: true`.
//! 4. Mint VMC + role VAC.
//! 5. Update the Member row with the new VMC + VAC ids.
//! 6. Emit `MembershipRenewed` audit.

use affinidi_status_list::StatusPurpose;
use serde::Serialize;
use serde_json::{Value as JsonValue, json};
use tracing::info;
use uuid::Uuid;

use vti_common::audit::{AuditEvent, MembershipRenewedData};
use vti_common::error::AppError;

use crate::acl::get_acl_entry;
use crate::credentials::{
    CredentialStatusRef, RoleVacParams, VmcParams, build_role_vac, build_vmc,
};
use crate::error::TaskError;
use crate::members::get_member;
use crate::policy::{
    PolicyPurpose, compile as compile_policy, evaluate as evaluate_policy, get_active_policy_id,
    get_policy,
};
use crate::server::AppState;
use crate::status_list;

/// `vtc/members/renew:notMember` — the caller is not a member.
pub const RENEW_ERR_NOT_MEMBER: &str =
    trust_tasks_rs::specs::vtc::members::renew::v0_1::error_codes::NOT_MEMBER.code;

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct RenewResponse {
    pub did: String,
    pub vmc: JsonValue,
    pub role_vac: JsonValue,
    /// `personhood.rego` re-eval outcome for the new VMC.
    /// Phase 2's deny-all default keeps this `false`; the
    /// field exists from day one so Phase 4's
    /// assert/revoke endpoints don't break the wire shape.
    pub personhood: bool,
    /// `true` when the personhood flag flipped from the prior
    /// VMC. Surfaced separately from `personhood` itself so
    /// callers can light up a "your personhood status
    /// changed" notification.
    pub personhood_changed: bool,
}

/// Renew `caller_did`'s VMC + role VAC — the operation behind the
/// `vtc/members/renew/0.1` Trust Task (`trust_tasks::member_tasks`), which
/// authorizes from the document's proof signer. Renewal has no bearer REST
/// route: it is a signed document only, reached over TSP, DIDComm or HTTPS
/// `/trust-tasks`.
pub(crate) async fn renew_inner(
    state: &AppState,
    caller_did: &str,
) -> Result<RenewResponse, TaskError> {
    let caller_did = caller_did.to_string();
    let audit_writer = state
        .audit_writer
        .as_ref()
        .ok_or_else(|| AppError::Internal("audit_writer not initialised".into()))?;

    // 1. Verify the caller has an active ACL row (spec §6.3 —
    // no expiry / grace window).
    //
    // Either row missing is `renew:notMember`: an ACL entry without a member
    // row (an operator grant) holds no membership to renew either.
    let _acl = get_acl_entry(&state.acl_ks, &caller_did)
        .await?
        .ok_or_else(|| {
            TaskError::declared(
                RENEW_ERR_NOT_MEMBER,
                AppError::NotFound(format!("no ACL row for {caller_did} — not a member")),
            )
        })?;

    // 2. Recover the prior Member row for the status-list slot
    // + the prior VMC's personhood flag (audit context).
    let member = get_member(&state.members_ks, &caller_did)
        .await?
        .ok_or_else(|| {
            TaskError::declared(
                RENEW_ERR_NOT_MEMBER,
                AppError::NotFound(format!("no Member row for {caller_did}")),
            )
        })?;

    let signer = state.credential_signer.as_ref().ok_or_else(|| {
        AppError::Internal(
            "credential signer not initialised — cannot renew (run setup first)".into(),
        )
    })?;

    let status_list_state =
        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(),
                )
            })?;

    // Slot: renewal reuses the same slot the member was
    // allocated at join time. A member without a slot somehow
    // (mid-Phase-1 row that pre-dates M2.12) gets a fresh
    // allocation — this keeps renewal idempotent for
    // grandfathered rows without forcing the operator to
    // reseed.
    let slot = match member.status_list_index {
        Some(s) => s,
        // Locked RMW (P0.1) — and `with_locked` re-reads the row inside
        // the lock, so the allocation can't be lost to (or built on a
        // stale copy clobbered by) a concurrent writer.
        None => {
            status_list::with_locked(&state.status_lists_ks, StatusPurpose::Revocation, |row| {
                status_list::allocate(row).ok_or_else(|| {
                    AppError::Internal(format!(
                        "revocation status list exhausted (capacity = {})",
                        row.capacity
                    ))
                })
            })
            .await?
        }
    };

    let status_ref =
        CredentialStatusRef::revocation(status_list_state.list_credential_id.clone(), slot);

    // 3. Re-evaluate `personhood.rego` against the Member's
    //    persisted state (Phase 4 M4.2.2).
    let prior_personhood = member.personhood;
    let policy_allow = evaluate_personhood(state, &member).await?;

    // M4.2.2: when the policy flips a previously-asserted
    // member's flag to `false`, branch on the operator's
    // configured `on_personhood_fail` mode.
    let fail_mode = state.config.read().await.renewal.on_personhood_fail;
    let (personhood, downgrade_audit) = if prior_personhood && !policy_allow {
        match fail_mode {
            crate::config::PersonhoodFailMode::Refuse => {
                // No state change, no audit, no VMC re-mint.
                return Err(AppError::Validation(
                    "personhood-renewal-refused: active personhood.rego \
                         no longer permits this member; re-assert via \
                         POST /v1/members/{did}/personhood/assert before \
                         retrying renewal"
                        .into(),
                )
                .into());
            }
            crate::config::PersonhoodFailMode::Downgrade => (false, true),
        }
    } else {
        (policy_allow, false)
    };

    let personhood_changed = prior_personhood != personhood;

    // 4. Build VMC + role VAC.
    let vmc_id = format!("urn:uuid:{}", Uuid::new_v4());
    let vmc = build_vmc(
        signer,
        VmcParams::new(&caller_did)
            .with_id(vmc_id.clone())
            .with_status_ref(status_ref)
            .with_personhood(personhood),
    )
    .await?;

    let vac_id = format!("urn:uuid:{}", Uuid::new_v4());
    let role_vac_acl = get_acl_entry(&state.acl_ks, &caller_did)
        .await?
        .ok_or_else(|| AppError::Internal("ACL row disappeared mid-renewal".into()))?;
    let role_vac = build_role_vac(
        signer,
        RoleVacParams::new(&caller_did, role_vac_acl.role.clone()).with_id(vac_id.clone()),
    )
    .await?;

    // 5. Update the Member row — re-read under the members edit lock, so a
    // field another writer changed meanwhile (a forge-account link) is kept.
    // Keep the bodies, not just the ids — see [`crate::members::Member::current_vmc`].
    // A renewed grant carries different claims and therefore a different digest, so
    // any acknowledgement bound to the previous one no longer matches it and is
    // dropped: `record_issued_credentials` does that, leaving the member visibly
    // owing a fresh one rather than a stale consent standing against a grant it
    // does not match.
    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, &caller_did, |m| {
        m.status_list_index = Some(slot);
        m.record_issued_credentials(vmc_value, role_vac_value);
        if downgrade_audit {
            // Renewal-policy downgrade clears the asserted-at
            // timestamp alongside the flag. The member must
            // re-assert (M4.3) to reinstate.
            m.personhood = false;
            m.personhood_asserted_at = None;
        }
        true
    })
    .await?
    .ok_or_else(|| AppError::Conflict("the member left while this was in progress".into()))?;

    // 6. Audit. `MembershipRenewed` always fires; paired
    //    `PersonhoodRevoked { reason: "renewal-policy" }` only
    //    on the downgrade arm.
    audit_writer
        .write(
            &caller_did,
            Some(&caller_did),
            AuditEvent::MembershipRenewed(MembershipRenewedData {
                vmc_id: vmc_id.clone(),
                role_vac_id: vac_id.clone(),
                personhood_changed,
            }),
        )
        .await?;
    if downgrade_audit {
        let vtc_did = state
            .config
            .read()
            .await
            .vtc_did
            .clone()
            .unwrap_or_else(|| "did:key:vtc-unknown".into());
        audit_writer
            .write(
                &vtc_did,
                Some(&caller_did),
                AuditEvent::PersonhoodRevoked(vti_common::audit::PersonhoodRevokedData {
                    vmc_id: Some(vmc_id.clone()),
                    reason: "renewal-policy".into(),
                }),
            )
            .await?;
    }

    info!(
        did = %caller_did,
        slot,
        personhood,
        personhood_changed,
        "membership renewed"
    );

    Ok(RenewResponse {
        did: caller_did,
        vmc: serde_json::to_value(&vmc)
            .map_err(|e| AppError::Internal(format!("serialise VMC: {e}")))?,
        role_vac: serde_json::to_value(&role_vac)
            .map_err(|e| AppError::Internal(format!("serialise VAC: {e}")))?,
        personhood,
        personhood_changed,
    })
}

/// Run the active `personhood.rego` against the renewal-time
/// canonical input (Phase 4 M4.2.2):
///
/// ```json
/// {
///   "applicant_did": "<did>",
///   "current_personhood": <bool>,
///   "asserted_at_seconds_ago": <int | null>,
///   "vp_claims": { "holder": "<did>", "credentials": [] }
/// }
/// ```
///
/// Evidence (the VP) is **not persisted** on the Member row
/// per planning-review D2 — the policy sees the current
/// flag + the assert age, not the original evidence. Operators
/// wanting richer renewal-time eval upload a custom rego that
/// consults additional inputs (e.g. live trust-registry).
///
/// Fail-closed: any error path yields `false`.
async fn evaluate_personhood(
    state: &AppState,
    member: &crate::members::Member,
) -> Result<bool, AppError> {
    let active = get_active_policy_id(&state.active_policies_ks, PolicyPurpose::Personhood).await?;
    let Some(id) = active else {
        return Ok(false);
    };
    let policy = get_policy(&state.policies_ks, id)
        .await?
        .ok_or_else(|| AppError::Internal(format!("active personhood policy {id} not found")))?;
    let compiled = compile_policy(&policy.rego_source, policy.id)?;
    let asserted_at_seconds_ago: JsonValue = match member.personhood_asserted_at {
        Some(t) => json!((chrono::Utc::now() - t).num_seconds().max(0)),
        None => JsonValue::Null,
    };
    // `community_did` is carried here too, even though renewal presents
    // no credentials for the identity-verification rule to match. One
    // policy module serves both call sites, so a rule that reads
    // `input.community_did` must not become undefined — and therefore
    // silently unsatisfiable — the moment renewal evaluates it.
    let community_did = state
        .credential_signer
        .as_ref()
        .map(|s| JsonValue::from(s.issuer_did()))
        .unwrap_or(JsonValue::Null);
    let input = json!({
        "applicant_did": member.did,
        "community_did": community_did,
        "current_personhood": member.personhood,
        "asserted_at_seconds_ago": asserted_at_seconds_ago,
        "vp_claims": { "holder": member.did, "credentials": [] },
    });
    let result = evaluate_policy(&compiled, "data.vtc.personhood.allow", input)?;
    Ok(result
        .pointer("/result/0/expressions/0/value")
        .and_then(|v| v.as_bool())
        .unwrap_or(false))
}