vtc-service 0.14.0

Service for Verifiable Trust Communities
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
//! `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);

    // An external card hangs off registry-publication consent (VTI-DIR-047):
    // withdrawn with it.
    if fields_changed.iter().any(|f| f == "publishConsent")
        && !member.publish_consent
        && let Some(entry_id) =
            crate::directory::cards::entry_of(state.directory.ks(), &member.did).await?
    {
        crate::directory::external::withdraw(
            state,
            &entry_id,
            crate::directory::external::Withdrawal::Consent,
        )
        .await?;
    }

    // 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,
        }
    }
}