#![allow(clippy::result_large_err)]
pub(crate) mod helpers;
pub(crate) mod accepted_ids;
mod credential_exchange;
pub mod size;
mod discovery;
mod acl_tasks;
pub(crate) mod action_tasks;
pub(crate) mod event_tasks;
pub(crate) mod admin_tasks;
pub(crate) mod community_tasks;
pub(crate) mod policy_tasks;
pub(crate) mod surface_tasks;
pub(crate) mod signing_key_tasks;
pub(crate) mod admin_passkey_tasks;
pub(crate) mod admin_session_tasks;
pub(crate) mod whoami_tasks;
#[cfg(feature = "website")]
pub(crate) mod website_tasks;
mod auth_tasks;
pub(crate) mod oob_tasks;
mod install_tasks;
mod recognise_tasks;
mod member_tasks;
mod wallet_session_tasks;
pub(crate) mod backup_tasks;
pub(crate) mod step_up_passkey_tasks;
pub(crate) mod step_up_approver_tasks;
pub(crate) mod community_room_tasks;
pub(crate) mod directory_tasks;
pub(crate) mod role_tasks;
pub(crate) mod room_admin_tasks;
pub(crate) mod oid4vp_tasks;
pub(crate) mod storage_tasks;
pub(crate) mod admission_tasks;
pub(crate) mod capability_module_tasks;
#[cfg(test)]
#[allow(dead_code)]
#[path = "../../tests/it/common/webauthn_harness.rs"]
mod soft_authenticator;
#[cfg(test)]
mod conformance;
#[cfg(test)]
mod error_code_census;
use serde_json::Value;
use trust_tasks_rs::specs::vtc::members::personhood::{assert::v0_2 as pa, challenge::v0_1 as pc};
use trust_tasks_rs::specs::vtc::members::{
admin_remove::v0_1 as member_admin_remove, credentials::v0_1 as member_credentials,
purge::v0_1 as member_purge, update::v0_1 as member_update,
};
use trust_tasks_rs::specs::vtc::community::profile::update::v0_1 as community_profile_update;
use trust_tasks_rs::specs::vtc::join_requests::decide::v0_1 as join_decide;
use trust_tasks_rs::specs::vtc::config::{
export::v0_1 as config_export, import::v0_1 as config_import,
};
use trust_tasks_rs::specs::vtc::endorsement_types::{
delete::v0_1 as endorsement_type_delete, register::v0_1 as endorsement_type_register,
};
use trust_tasks_rs::specs::vtc::backup::export::v0_1 as backup_export;
use trust_tasks_rs::specs::acl::change_role::v0_1 as acl_change_role;
use trust_tasks_rs::specs::acl::grant::v0_1 as acl_grant;
use trust_tasks_rs::specs::acl::{
list::v0_1 as acl_list, revoke::v0_1 as acl_revoke, show::v0_1 as acl_show,
update::v0_1 as acl_update,
};
use trust_tasks_rs::specs::auth::step_up::approve_response::v0_4 as step_up_approve_response;
use trust_tasks_rs::specs::auth::step_up::approve_response::v0_5 as step_up_approve_response_v0_5;
use trust_tasks_rs::specs::auth::step_up::approve_response::v0_6 as step_up_approve_response_v0_6;
use trust_tasks_rs::{RejectReason, TrustTask};
use vta_sdk::protocols::trust_task_reject_reasons as reasons;
use vti_common::error::AppError;
use vta_sdk::protocols::join_requests::{
self as jr, JoinRequestStatusBody, JoinRequestSubmitBody, VerdictResponse,
};
use vta_sdk::protocols::members::{self as mem, MemberVmcBody, MemberVmcReceiptBody};
use vta_sdk::protocols::vetting::{
self as vetting_wire, revoke_statement::v0_1 as revoke_statement,
};
use crate::join::{JoinSubmitOutcome, JoinTransport};
use crate::server::AppState;
pub(crate) use helpers::TrustTaskOutcome;
pub(crate) use helpers::framework_error_type_uri;
use helpers::{
app_error_to_reject, body_parse_error_response, extended_code, parse_payload, reject_with,
reject_with_code, reject_with_code_because, success_response, task_error_to_reject,
verdict_response, verify_received_approval_proof, verify_received_trust_task_proof,
verify_trust_task_proof,
};
pub(crate) struct JoinAuthCtx {
pub transport: JoinTransport,
pub sender_did: Option<String>,
pub verified_signer: Option<String>,
}
impl JoinAuthCtx {
#[allow(dead_code)]
pub fn didcomm(sender_did: String) -> Self {
Self {
transport: JoinTransport::DIDComm,
sender_did: Some(sender_did),
verified_signer: None,
}
}
#[allow(dead_code)]
pub fn rest() -> Self {
Self {
transport: JoinTransport::Rest,
sender_did: None,
verified_signer: None,
}
}
fn with_verified_signer(&self, signer: Option<String>) -> Self {
Self {
transport: self.transport,
sender_did: self.sender_did.clone(),
verified_signer: signer,
}
}
}
pub(crate) async fn dispatch_trust_task_core(
state: &AppState,
ctx: &JoinAuthCtx,
body: &[u8],
) -> TrustTaskOutcome {
let address = large_document_budget_address(ctx);
let outcome = dispatch_trust_task_validated(state, ctx, body, None, &address).await;
sign_response(state, outcome).await
}
fn large_document_budget_address(ctx: &JoinAuthCtx) -> String {
ctx.sender_did
.as_deref()
.map(|vid| format!("vid:{vid}"))
.unwrap_or_else(|| "ip:unknown".to_string())
}
pub(crate) trait VerifiedAdmission: Sync {
fn admit_verified(&self, signer: &str) -> bool;
fn room_precharged(&self) -> bool {
false
}
}
pub(crate) async fn dispatch_trust_task_core_admitted(
state: &AppState,
ctx: &JoinAuthCtx,
body: &[u8],
admit: &dyn VerifiedAdmission,
client_ip: std::net::IpAddr,
) -> TrustTaskOutcome {
let address = format!("ip:{client_ip}");
let outcome = dispatch_trust_task_validated(state, ctx, body, Some(admit), &address).await;
sign_response(state, outcome).await
}
async fn dispatch_trust_task_validated(
state: &AppState,
ctx: &JoinAuthCtx,
body: &[u8],
admit: Option<&dyn VerifiedAdmission>,
budget_address: &str,
) -> TrustTaskOutcome {
let large_doc_charge = match size::check_for_known_issuer_with(
state,
body,
budget_address,
crate::auth::session::now_epoch(),
admit.is_some_and(|a| a.room_precharged()),
)
.await
{
Ok(charge) => charge,
Err(refused) => return refused,
};
let doc: TrustTask<Value> = match serde_json::from_slice(body) {
Ok(d) => d,
Err(e) => return body_parse_error_response(&e.to_string()),
};
let received: Value = match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => return body_parse_error_response(&e.to_string()),
};
let now = chrono::Utc::now();
if let Err(reason) = doc.validate_freshness(now, &freshness_policy()) {
return reject_with(&doc, reason);
}
let vtc_did = state.config.read().await.vtc_did.clone();
if let Some(vtc_did) = vtc_did
&& let Err(reason) = doc.validate_basic(now, &vtc_did)
{
return reject_with(&doc, reason);
}
let type_uri = doc.type_uri.to_string();
if let Some(policy) = spec_policy_for(&type_uri)
&& let Err(reason) = policy.enforce(&doc)
{
tracing::info!(
type_uri,
?reason,
"document refused by its specification's own policy"
);
return reject_with(&doc, reason);
}
if let Some(refused) = oob_tasks::precheck(&doc, &type_uri) {
return refused;
}
if type_uri == install_tasks::CLAIM_FINISH_V0_3_TYPE
&& let Some(refused) = install_tasks::refresh_founder_did(state, &doc).await
{
return refused;
}
let ctx = if doc.proof.is_some() {
let verified = if helpers::is_approval_type(&type_uri) {
verify_received_approval_proof(state, &received, &type_uri).await
} else {
verify_received_trust_task_proof(state, &received).await
};
match verified {
Ok(signer) => {
if doc.issuer.as_deref() != Some(signer.as_str()) {
tracing::warn!(
type_uri,
issuer = ?doc.issuer,
%signer,
"proof verifies under a key the document's issuer does not control"
);
size::settle_large_document_charge(
&state.large_document_budget,
large_doc_charge.as_ref(),
Some(signer.as_str()),
);
return reject_with(
&doc,
RejectReason::ProofInvalid {
reason: "the proof's verificationMethod does not belong to the \
document's issuer (SPEC §4.7)"
.to_string(),
},
);
}
size::settle_large_document_charge(
&state.large_document_budget,
large_doc_charge.as_ref(),
Some(signer.as_str()),
);
if helpers::is_approval_type(&type_uri) {
match crate::acl::console_key::get_delegation(&state.console_keys_ks, &signer)
.await
{
Ok(None) => {}
Ok(Some(_)) if type_uri == STEP_UP_APPROVE_RESPONSE_V0_6_TYPE => {
return reject_with_code(
&doc,
helpers::extended_code(
step_up_approve_response_v0_6::error_codes::SUBJECT_MISMATCH
.code,
),
"a delegated signing key is never accepted as the subject's own \
proof; sign the approve-response with the subject DID's own key",
None,
);
}
Ok(Some(_)) => {
return reject_with(
&doc,
RejectReason::PermissionDenied {
reason: "a delegated signing key is never accepted as an \
approver's attestation; sign with the approver's \
own key, or answer a passkey step-up unsigned"
.to_string(),
},
);
}
Err(e) => return app_error_to_reject(&doc, &e),
}
}
&ctx.with_verified_signer(Some(signer))
}
Err(e) => {
size::settle_large_document_charge(
&state.large_document_budget,
large_doc_charge.as_ref(),
None,
);
return app_error_to_reject(&doc, &e);
}
}
} else {
size::settle_large_document_charge(
&state.large_document_budget,
large_doc_charge.as_ref(),
None,
);
ctx
};
if matches!(ctx.transport, JoinTransport::DIDComm | JoinTransport::Tsp) {
let base = |d: &str| d.split('#').next().unwrap_or(d).to_string();
match (ctx.verified_signer.as_deref(), ctx.sender_did.as_deref()) {
(Some(signer), Some(sender)) if base(signer) == base(sender) => {}
(None, _) => {
tracing::warn!(type_uri, "messaging document carries no proof — refused");
return reject_with(&doc, RejectReason::ProofRequired);
}
(Some(signer), sender) => {
tracing::warn!(
type_uri,
%signer,
sender = ?sender,
"messaging document is signed by a DID other than its sender — refused"
);
return reject_with(
&doc,
RejectReason::IdentityMismatch(
trust_tasks_rs::ConsistencyError::IssuerMismatch {
in_band: signer.to_string(),
transport: sender.unwrap_or_default().to_string(),
},
),
);
}
}
}
if let (Some(admit), Some(signer)) = (admit, ctx.verified_signer.as_deref())
&& !admit.admit_verified(signer)
{
return TrustTaskOutcome {
status: axum::http::StatusCode::TOO_MANY_REQUESTS,
body: Vec::new(),
};
}
let claim = match state
.accepted_ids()
.claim(&doc, retain_until(&doc, now), now)
.await
{
Ok(accepted_ids::Acceptance::Fresh(claim)) => claim,
Ok(accepted_ids::Acceptance::Duplicate {
prior_response,
in_flight,
}) => {
return match prior_response {
Some(v) => match serde_json::to_vec(&v) {
Ok(body) => TrustTaskOutcome {
status: axum::http::StatusCode::OK,
body,
},
Err(e) => reject_with(
&doc,
RejectReason::InternalError {
reason: format!("prior response is unserialisable: {e}"),
},
),
},
None if in_flight => TrustTaskOutcome {
status: axum::http::StatusCode::ACCEPTED,
body: Vec::new(),
},
None => TrustTaskOutcome {
status: axum::http::StatusCode::NO_CONTENT,
body: Vec::new(),
},
};
}
Ok(accepted_ids::Acceptance::Conflict) => {
return reject_with(&doc, RejectReason::IdConflict);
}
Err(e) => return reject_with(&doc, e.reject_reason()),
};
let submission = crate::admin_actions::Submission {
received: std::sync::Arc::new(received),
signer: ctx.verified_signer.clone(),
transport: ctx.transport,
waiver: Default::default(),
immediate: Default::default(),
};
let outcome = crate::admin_actions::with_submission(
submission,
dispatch_typed(state, ctx, doc, &type_uri),
)
.await;
let outcome = sign_response(state, outcome).await;
if outcome.status.is_success() {
let recorded = if step_up_passkey_tasks::SECRET_RESPONSES.contains(&type_uri.as_str())
|| step_up_approver_tasks::SECRET_RESPONSES.contains(&type_uri.as_str())
|| admin_tasks::SECRET_RESPONSES.contains(&type_uri.as_str())
|| community_tasks::SECRET_RESPONSES.contains(&type_uri.as_str())
|| event_tasks::UNRECORDED_RESPONSES.contains(&type_uri.as_str())
{
None
} else {
serde_json::from_slice::<serde_json::Value>(&outcome.body).ok()
};
claim.completed(recorded.as_ref()).await;
} else {
claim.release().await;
}
outcome
}
pub(crate) fn spec_policy_for(type_uri: &str) -> Option<trust_tasks_rs::SpecPolicy> {
trust_tasks_rs::schema_index::spec_policy_for(type_uri)
}
pub(super) fn freshness_policy() -> trust_tasks_rs::FreshnessPolicy {
vti_common::trust_task::acceptance::VTI_ACCEPTANCE_WINDOW
.freshness_policy()
.requiring_issued_at()
}
fn retain_until(
doc: &TrustTask<Value>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<chrono::DateTime<chrono::Utc>> {
let policy = freshness_policy();
let expiry = policy.record_expiry(doc, now)?;
match (doc.issued_at, policy.max_age) {
(Some(issued_at), Some(max_age)) => Some(expiry.min(issued_at + max_age + policy.skew)),
_ => Some(expiry),
}
}
pub(crate) async fn sign_response(state: &AppState, outcome: TrustTaskOutcome) -> TrustTaskOutcome {
if outcome.body.is_empty() {
return outcome;
}
let Some(signer) = state.credential_signer.clone() else {
return outcome;
};
let mut doc: Value = match serde_json::from_slice(&outcome.body) {
Ok(d) => d,
Err(e) => {
tracing::error!(error = %e, "success response is not JSON; returning it unsigned");
return outcome;
}
};
if doc.get("proof").is_some() {
return outcome;
}
if let Err(e) = signer.sign_operational_response(&mut doc).await {
tracing::error!(error = %e, "could not sign the response; returning it unsigned");
return outcome;
}
match serde_json::to_vec(&doc) {
Ok(body) => TrustTaskOutcome {
status: outcome.status,
body,
},
Err(e) => {
tracing::error!(error = %e, "could not serialise the signed response");
outcome
}
}
}
async fn dispatch_parked(
state: &AppState,
ctx: JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let type_uri = doc.type_uri.to_string();
let outcome = dispatch_typed(state, &ctx, doc, &type_uri).await;
sign_response(state, outcome).await
}
pub(crate) fn dispatch_parked_boxed<'a>(
state: &'a AppState,
ctx: JoinAuthCtx,
doc: TrustTask<Value>,
exec: crate::admin_actions::Executing,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = TrustTaskOutcome> + Send + 'a>> {
Box::pin(crate::admin_actions::executing_scope(
exec,
dispatch_parked(state, ctx, doc),
))
}
fn dispatch_typed<'a>(
state: &'a AppState,
ctx: &'a JoinAuthCtx,
doc: TrustTask<Value>,
type_uri: &'a str,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = TrustTaskOutcome> + Send + 'a>> {
let rooms_presenter = ctx.verified_signer.as_deref();
match type_uri {
jr::JOIN_REQUEST_SUBMIT_TYPE => {
Box::pin(async move { handle_submit(state, ctx, doc).await })
}
jr::JOIN_REQUEST_MANIFEST_TYPE => {
Box::pin(async move { handle_manifest(state, ctx, doc).await })
}
jr::JOIN_REQUEST_STATUS_TYPE => {
Box::pin(async move { handle_status(state, ctx, doc).await })
}
jr::JOIN_REQUEST_WITHDRAW_TYPE => {
Box::pin(async move { handle_withdraw(state, ctx, doc).await })
}
jr::JOIN_REQUEST_SUPPLEMENT_TYPE => {
Box::pin(async move { handle_supplement(state, ctx, doc).await })
}
jr::JOIN_REQUEST_WALLET_SESSION_START_TYPE => {
Box::pin(async move { wallet_session_tasks::handle_start(state, ctx, doc).await })
}
jr::JOIN_REQUEST_WALLET_SESSION_STATUS_TYPE => {
Box::pin(async move { wallet_session_tasks::handle_status(state, ctx, doc).await })
}
jr::MEMBER_SELF_REMOVE_TYPE => {
Box::pin(async move { handle_self_remove(state, ctx, doc).await })
}
mem::MEMBER_VMC_TYPE => Box::pin(async move { handle_member_vmc(state, ctx, doc).await }),
vta_sdk::protocols::credential_exchange::REQUEST => {
Box::pin(async move { credential_exchange::handle_request(state, ctx, doc).await })
}
vta_sdk::protocols::credential_exchange::PRESENT => {
Box::pin(
async move { Box::pin(credential_exchange::handle_present(state, ctx, doc)).await },
)
}
vetting_wire::VETTING_REVOKE_STATEMENT_TYPE => {
Box::pin(async move { handle_revoke_statement(state, ctx, doc).await })
}
vetting_wire::VETTING_VETTER_GRANT_TYPE => {
Box::pin(async move { handle_vetter_grant(state, ctx, doc).await })
}
vetting_wire::VETTING_VETTER_PROFILE_TYPE => {
Box::pin(async move { handle_vetter_profile(state, ctx, doc).await })
}
vetting_wire::VETTING_VETTER_LIST_TYPE => {
Box::pin(async move { handle_vetter_list(state, ctx, doc).await })
}
vetting_wire::VETTING_VETTER_SHOW_TYPE => {
Box::pin(async move { handle_vetter_show(state, ctx, doc).await })
}
vetting_wire::VETTING_VETTER_RESEND_TYPE => {
Box::pin(async move { handle_vetter_resend(state, ctx, doc).await })
}
vetting_wire::VETTING_VETTER_RESEND_0_2_TYPE => {
Box::pin(async move { handle_vetter_resend_v0_2(state, ctx, doc).await })
}
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::PCS_ROOT_TYPE => {
Box::pin(async move { handle_pcs_root(state, ctx, doc).await })
}
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::PCS_TOKENS_TYPE => {
Box::pin(async move { handle_pcs_tokens(state, ctx, doc).await })
}
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::EVENT_MODE_TYPE => {
Box::pin(async move { handle_event_mode(state, ctx, doc).await })
}
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::PCS_CHALLENGE_TYPE => {
Box::pin(async move { handle_pcs_challenge(state, ctx, doc).await })
}
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::HIDDEN_PUBLISH_TYPE => {
Box::pin(async move { handle_hidden_publish(state, ctx, doc).await })
}
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::HIDDEN_WITHDRAW_TYPE => {
Box::pin(async move { handle_hidden_withdraw(state, ctx, doc).await })
}
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::HIDDEN_SHOW_TYPE => {
Box::pin(async move { handle_hidden_show(state, ctx, doc).await })
}
uri if crate::rooms::handlers::serves(uri) => Box::pin(async move {
let Some(presenter) = rooms_presenter else {
return reject_with(&doc, trust_tasks_rs::RejectReason::ProofRequired);
};
crate::rooms::handlers::dispatch(state, doc, presenter).await
}),
uri if backup_tasks::URIS.contains(&uri) => Box::pin(async move {
match backup_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("backup_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if admin_tasks::URIS.contains(&uri) => Box::pin(async move {
match admin_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("admin_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if oob_tasks::URIS.contains(&uri) => Box::pin(async move {
match oob_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("oob_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if auth_tasks::URIS.contains(&uri) => Box::pin(async move {
match auth_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("auth_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if install_tasks::URIS.contains(&uri) => Box::pin(async move {
match install_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("install_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if recognise_tasks::URIS.contains(&uri) => Box::pin(async move {
match recognise_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => {
unreachable!("recognise_tasks::URIS names {uri}, which it does not route")
}
}
}),
#[cfg(feature = "website")]
uri if website_tasks::URIS.contains(&uri) => Box::pin(async move {
match website_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("website_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if admin_passkey_tasks::URIS.contains(&uri) => Box::pin(async move {
match admin_passkey_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => {
unreachable!("admin_passkey_tasks::URIS names {uri}, which it does not route")
}
}
}),
uri if whoami_tasks::URIS.contains(&uri) => Box::pin(async move {
match whoami_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("whoami_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if admin_session_tasks::URIS.contains(&uri) => Box::pin(async move {
match admin_session_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => {
unreachable!("admin_session_tasks::URIS names {uri}, which it does not route")
}
}
}),
uri if signing_key_tasks::URIS.contains(&uri) => Box::pin(async move {
match signing_key_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => {
unreachable!("signing_key_tasks::URIS names {uri}, which it does not route")
}
}
}),
uri if surface_tasks::URIS.contains(&uri) => Box::pin(async move {
match surface_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("surface_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if policy_tasks::URIS.contains(&uri) => Box::pin(async move {
match policy_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("policy_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if storage_tasks::URIS.contains(&uri) => Box::pin(async move {
match Box::pin(storage_tasks::dispatch(state, ctx, doc, uri)).await {
Some(outcome) => outcome,
None => unreachable!("storage_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if oid4vp_tasks::URIS.contains(&uri) => Box::pin(async move {
match Box::pin(oid4vp_tasks::dispatch(state, ctx, doc, uri)).await {
Some(outcome) => outcome,
None => unreachable!("oid4vp_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if directory_tasks::URIS.contains(&uri) => Box::pin(async move {
match Box::pin(directory_tasks::dispatch(state, ctx, doc, uri)).await {
Some(outcome) => outcome,
None => unreachable!("directory_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if community_room_tasks::URIS.contains(&uri) => Box::pin(async move {
match Box::pin(community_room_tasks::dispatch(state, ctx, doc, uri)).await {
Some(outcome) => outcome,
None => {
unreachable!("community_room_tasks::URIS names {uri}, which it does not route")
}
}
}),
uri if room_admin_tasks::URIS.contains(&uri) => Box::pin(async move {
match Box::pin(room_admin_tasks::dispatch(state, ctx, doc, uri)).await {
Some(outcome) => outcome,
None => unreachable!("room_admin_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if role_tasks::URIS.contains(&uri) => Box::pin(async move {
match Box::pin(role_tasks::dispatch(state, ctx, doc, uri)).await {
Some(outcome) => outcome,
None => unreachable!("role_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if community_tasks::URIS.contains(&uri) => Box::pin(async move {
match community_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => {
unreachable!("community_tasks::URIS names {uri}, which it does not route")
}
}
}),
uri if step_up_passkey_tasks::URIS.contains(&uri) => Box::pin(async move {
match step_up_passkey_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => {
unreachable!("step_up_passkey_tasks::URIS names {uri}, which it does not route")
}
}
}),
uri if step_up_approver_tasks::URIS.contains(&uri) => Box::pin(async move {
match step_up_approver_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => {
unreachable!(
"step_up_approver_tasks::URIS names {uri}, which it does not route"
)
}
}
}),
uri if capability_module_tasks::URIS.contains(&uri) => Box::pin(async move {
match capability_module_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!(
"capability_module_tasks::URIS names {uri}, which it does not route"
),
}
}),
uri if admission_tasks::URIS.contains(&uri) => Box::pin(async move {
match admission_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("admission_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if member_tasks::URIS.contains(&uri) => Box::pin(async move {
match member_tasks::dispatch(state, ctx, doc, uri).await {
Some(outcome) => outcome,
None => unreachable!("member_tasks::URIS names {uri}, which it does not route"),
}
}),
uri if crate::git_ns::tasks::serves(uri) => Box::pin(async move {
crate::git_ns::tasks::dispatch(
state,
doc,
ctx.verified_signer.as_deref(),
ctx.sender_did.as_deref(),
)
.await
}),
PERSONHOOD_CHALLENGE_TYPE => {
Box::pin(async move { handle_personhood_challenge(state, ctx, doc).await })
}
PERSONHOOD_ASSERT_TYPE => {
Box::pin(async move { handle_personhood_assert(state, ctx, doc).await })
}
MEMBER_CREDENTIALS_TYPE => {
Box::pin(async move { handle_member_credentials(state, ctx, doc).await })
}
MEMBER_UPDATE_TYPE => Box::pin(async move { handle_member_update(state, ctx, doc).await }),
MEMBER_ADMIN_REMOVE_TYPE => {
Box::pin(async move { handle_member_admin_remove(state, ctx, doc).await })
}
MEMBER_PURGE_TYPE => Box::pin(async move { handle_member_purge(state, ctx, doc).await }),
JOIN_DECIDE_TYPE => Box::pin(async move { handle_join_decide(state, ctx, doc).await }),
COMMUNITY_PROFILE_UPDATE_TYPE => {
Box::pin(async move { handle_community_profile_update(state, ctx, doc).await })
}
CONFIG_EXPORT_TYPE => Box::pin(async move { handle_config_export(state, ctx, doc).await }),
CONFIG_IMPORT_TYPE => Box::pin(async move { handle_config_import(state, ctx, doc).await }),
ENDORSEMENT_TYPE_REGISTER_TYPE => {
Box::pin(async move { handle_endorsement_type_register(state, ctx, doc).await })
}
ENDORSEMENT_TYPE_DELETE_TYPE => {
Box::pin(async move { handle_endorsement_type_delete(state, ctx, doc).await })
}
BACKUP_EXPORT_TYPE => Box::pin(async move { handle_backup_export(state, ctx, doc).await }),
ACL_GRANT_TYPE => Box::pin(async move { acl_tasks::handle_grant(state, ctx, doc).await }),
ACL_CHANGE_ROLE_TYPE => {
Box::pin(async move { acl_tasks::handle_change_role(state, ctx, doc).await })
}
ACL_SHOW_TYPE => Box::pin(async move { acl_tasks::handle_show(state, ctx, doc).await }),
ACL_LIST_TYPE => Box::pin(async move { acl_tasks::handle_list(state, ctx, doc).await }),
ACL_UPDATE_TYPE => Box::pin(async move { acl_tasks::handle_update(state, ctx, doc).await }),
ACL_REVOKE_TYPE => Box::pin(async move { acl_tasks::handle_revoke(state, ctx, doc).await }),
ACL_GRANT_V0_2_TYPE => {
Box::pin(async move { acl_tasks::handle_grant_v0_2(state, ctx, doc).await })
}
ACL_CHANGE_ROLE_V0_2_TYPE => {
Box::pin(async move { acl_tasks::handle_change_role_v0_2(state, ctx, doc).await })
}
ACL_SHOW_V0_2_TYPE => {
Box::pin(async move { acl_tasks::handle_show_v0_2(state, ctx, doc).await })
}
ACL_LIST_V0_2_TYPE => {
Box::pin(async move { acl_tasks::handle_list_v0_2(state, ctx, doc).await })
}
ACL_UPDATE_V0_2_TYPE => {
Box::pin(async move { acl_tasks::handle_update_v0_2(state, ctx, doc).await })
}
ACL_REVOKE_V0_2_TYPE => {
Box::pin(async move { acl_tasks::handle_revoke_v0_2(state, ctx, doc).await })
}
ACL_SWAP_KEY_TYPE => {
Box::pin(async move { acl_tasks::handle_swap_key(state, ctx, doc).await })
}
STEP_UP_APPROVE_RESPONSE_TYPE => {
Box::pin(async move { handle_step_up_approve_response(state, ctx, doc).await })
}
STEP_UP_APPROVE_RESPONSE_V0_5_TYPE => {
Box::pin(async move { handle_step_up_approve_response_v0_5(state, ctx, doc).await })
}
STEP_UP_APPROVE_RESPONSE_V0_6_TYPE => {
Box::pin(async move { handle_step_up_approve_response_v0_6(state, ctx, doc).await })
}
crate::acl::admin_consent::DECISION_TYPE
| crate::acl::admin_consent::DECISION_V0_2_TYPE => {
Box::pin(async move { action_tasks::handle_decision(state, ctx, doc).await })
}
event_tasks::SUBSCRIBE_TYPE | event_tasks::SUBSCRIBE_V0_2_TYPE => {
Box::pin(async move { event_tasks::handle_subscribe(state, ctx, doc).await })
}
uri if action_tasks::URIS.contains(&uri) => Box::pin(async move {
match Box::pin(action_tasks::dispatch(state, ctx, doc, uri)).await {
Some(outcome) => outcome,
None => unreachable!("action_tasks::URIS names {uri}, which it does not route"),
}
}),
discovery::DISCOVERY_V0_3_TYPE => Box::pin(async move { discovery::handle(ctx, doc) }),
other => Box::pin(async move { unsupported_type_or_version(&doc, other) }),
}
}
#[cfg(test)]
mod spine_proof_tests {
use super::*;
use crate::test_support::{TEST_VTC_DID, build_test_vtc};
use serde_json::json;
use vti_rooms_dtg::test_support::Party as Holder;
fn holder() -> Holder {
Holder::new()
}
fn unsigned(h: &Holder, uri: &str, payload: Value) -> TrustTask<Value> {
vta_sdk::trust_task_sign::build_unsigned(uri, payload, &h.did, TEST_VTC_DID)
.expect("build the document")
}
async fn signed(h: &Holder, doc: TrustTask<Value>) -> TrustTask<Value> {
let mut doc = doc;
let key = vta_sdk::trust_task_sign::HolderKey::from_did_key(&h.did, &h.secret_multibase)
.expect("a did:key names its own verification method");
vta_sdk::trust_task_sign::sign_in_place_with(&mut doc, &key)
.await
.expect("sign the document");
doc
}
fn error_code(out: &TrustTaskOutcome) -> Option<String> {
let doc: Value = serde_json::from_slice(&out.body).ok()?;
doc.pointer("/payload/code")?.as_str().map(str::to_string)
}
async fn dispatch(state: &AppState, doc: &TrustTask<Value>) -> TrustTaskOutcome {
let body = serde_json::to_vec(doc).expect("a document serialises");
dispatch_trust_task_core(state, &JoinAuthCtx::rest(), &body).await
}
const UNDER_TEST: &str = jr::JOIN_REQUEST_STATUS_TYPE;
#[test]
fn the_task_under_test_declares_a_proof_required() {
let policy = trust_tasks_rs::schema_index::spec_policy_for(UNDER_TEST)
.expect("the task under test has a published spec policy");
assert!(
policy.is_proof_required,
"{UNDER_TEST} no longer declares proof REQUIRED — these tests now \
assert nothing, and the design note should be re-read"
);
}
#[tokio::test]
async fn vti_ops_020_a_proof_required_task_is_refused_without_a_proof() {
let tv = build_test_vtc().await;
let h = holder();
let out = dispatch(&tv.state, &unsigned(&h, UNDER_TEST, json!({}))).await;
assert_eq!(
error_code(&out).as_deref(),
Some("proofRequired"),
"SPEC §7.2 item 7 names the code: {}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn vti_ops_020_the_same_document_is_accepted_once_it_is_signed() {
let tv = build_test_vtc().await;
let h = holder();
let doc = signed(&h, unsigned(&h, UNDER_TEST, json!({}))).await;
let out = dispatch(&tv.state, &doc).await;
assert_ne!(
error_code(&out).as_deref(),
Some("proofRequired"),
"a signed document must reach its handler: {}",
String::from_utf8_lossy(&out.body)
);
assert_ne!(
error_code(&out).as_deref(),
Some("proofInvalid"),
"the spine must accept a proof it can verify: {}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn vti_ops_020_a_proof_by_a_key_the_issuer_does_not_control_is_refused() {
let tv = build_test_vtc().await;
let issuer = holder();
let impostor = holder();
let doc = signed(&impostor, unsigned(&issuer, UNDER_TEST, json!({}))).await;
let out = dispatch(&tv.state, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("proofInvalid"),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn vti_ops_024_an_issued_at_older_than_the_window_is_refused() {
let tv = build_test_vtc().await;
let h = holder();
let mut doc = unsigned(&h, UNDER_TEST, json!({}));
doc.issued_at = Some(chrono::Utc::now() - chrono::TimeDelta::hours(2));
let doc = signed(&h, doc).await;
assert_eq!(
error_code(&dispatch(&tv.state, &doc).await).as_deref(),
Some("expired"),
);
}
#[tokio::test]
async fn vti_ops_024_a_future_dated_issued_at_is_malformed_not_expired() {
let tv = build_test_vtc().await;
let h = holder();
let mut doc = unsigned(&h, UNDER_TEST, json!({}));
doc.issued_at = Some(chrono::Utc::now() + chrono::TimeDelta::minutes(30));
let doc = signed(&h, doc).await;
assert_eq!(
error_code(&dispatch(&tv.state, &doc).await).as_deref(),
Some("malformedRequest"),
);
}
#[tokio::test]
async fn vti_ops_024_a_document_inside_the_skew_tolerance_is_accepted() {
let tv = build_test_vtc().await;
let h = holder();
let mut doc = unsigned(&h, UNDER_TEST, json!({}));
doc.issued_at = Some(chrono::Utc::now() + chrono::TimeDelta::seconds(30));
let doc = signed(&h, doc).await;
let out = dispatch(&tv.state, &doc).await;
assert_ne!(
error_code(&out).as_deref(),
Some("malformedRequest"),
"60s of skew is SPEC §4.2's own tolerance: {}",
String::from_utf8_lossy(&out.body)
);
}
async fn seed_open_request(state: &AppState, h: &Holder) {
let request =
crate::join::JoinRequest::new(h.did.clone(), serde_json::json!({ "vp": "x" }));
crate::join::storage::store_join_request(&state.join_requests_ks, &request)
.await
.expect("seed an open join request");
}
#[tokio::test]
async fn vti_ops_025_a_replayed_document_is_answered_not_executed_again() {
let tv = build_test_vtc().await;
let h = holder();
seed_open_request(&tv.state, &h).await;
let doc = signed(&h, unsigned(&h, UNDER_TEST, json!({}))).await;
let first = dispatch(&tv.state, &doc).await;
assert!(
first.status.is_success(),
"the fixture must reach a succeeding handler, or the claim is \
released and the second dispatch runs fresh: {}",
String::from_utf8_lossy(&first.body)
);
let second = dispatch(&tv.state, &doc).await;
assert_eq!(first.status, second.status);
let as_json = |out: &TrustTaskOutcome| -> Value {
serde_json::from_slice(&out.body).expect("the answer is a document")
};
assert_eq!(
as_json(&first),
as_json(&second),
"a redelivery is the same document, so it gets the same answer"
);
}
#[tokio::test]
async fn vti_ops_025_a_different_document_reusing_an_id_is_a_conflict() {
let tv = build_test_vtc().await;
let h = holder();
seed_open_request(&tv.state, &h).await;
let first = signed(&h, unsigned(&h, UNDER_TEST, json!({}))).await;
let out = dispatch(&tv.state, &first).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let mut second = unsigned(&h, UNDER_TEST, json!({}));
second.id = first.id.clone();
second.issued_at = Some(chrono::Utc::now() - chrono::TimeDelta::seconds(1));
let second = signed(&h, second).await;
assert_eq!(
error_code(&dispatch(&tv.state, &second).await).as_deref(),
Some("idConflict"),
);
}
#[test]
fn vti_ops_026_retention_is_capped_at_the_end_of_the_acceptance_window() {
let h = holder();
let now = chrono::Utc::now();
let mut doc = unsigned(&h, UNDER_TEST, json!({}));
doc.issued_at = Some(now);
doc.expires_at = Some(now + chrono::TimeDelta::days(3650));
let policy = freshness_policy();
let until = retain_until(&doc, now).expect("a document with an issuedAt is boundable");
assert_eq!(
until,
now + policy.max_age.expect("the window is set") + policy.skew,
"retention may not outlast the window in which the document is \
still executable"
);
}
#[test]
fn vti_ops_026_a_short_expiry_does_not_shorten_retention_below_the_window() {
let h = holder();
let now = chrono::Utc::now();
let mut doc = unsigned(&h, UNDER_TEST, json!({}));
doc.issued_at = Some(now);
doc.expires_at = Some(now + chrono::TimeDelta::seconds(30));
let until = retain_until(&doc, now).expect("boundable");
assert_eq!(
until,
now + chrono::TimeDelta::seconds(30),
"a producer that says its document lapses in 30s has said the \
record may be dropped then — it is unacceptable after that too"
);
}
#[tokio::test]
async fn vti_ops_021_a_missing_proof_is_refused_on_every_transport() {
let tv = build_test_vtc().await;
let h = holder();
let body =
serde_json::to_vec(&unsigned(&h, UNDER_TEST, json!({}))).expect("serialise document");
for ctx in [
JoinAuthCtx::rest(),
JoinAuthCtx::didcomm(h.did.clone()),
JoinAuthCtx {
transport: JoinTransport::Tsp,
sender_did: Some(h.did.clone()),
verified_signer: None,
},
] {
let transport = ctx.transport.as_str();
let out = dispatch_trust_task_core(&tv.state, &ctx, &body).await;
assert_eq!(
error_code(&out).as_deref(),
Some("proofRequired"),
"{transport}: an authenticated sender is not a proof by the issuer: {}",
String::from_utf8_lossy(&out.body),
);
}
}
#[tokio::test]
async fn vti_ops_021_the_same_document_signed_is_accepted_on_every_transport() {
let tv = build_test_vtc().await;
let h = holder();
for ctx in [
JoinAuthCtx::rest(),
JoinAuthCtx::didcomm(h.did.clone()),
JoinAuthCtx {
transport: JoinTransport::Tsp,
sender_did: Some(h.did.clone()),
verified_signer: None,
},
] {
let transport = ctx.transport.as_str();
let doc = signed(&h, unsigned(&h, UNDER_TEST, json!({}))).await;
let body = serde_json::to_vec(&doc).expect("serialise document");
let out = dispatch_trust_task_core(&tv.state, &ctx, &body).await;
assert_ne!(
error_code(&out).as_deref(),
Some("proofRequired"),
"{transport}: a signed document must reach its handler: {}",
String::from_utf8_lossy(&out.body),
);
assert_ne!(
error_code(&out).as_deref(),
Some("proofInvalid"),
"{transport}: the spine must accept a proof it can verify: {}",
String::from_utf8_lossy(&out.body),
);
}
}
#[tokio::test]
async fn vti_09_a_bare_payload_is_refused_as_malformed_on_every_transport() {
let tv = build_test_vtc().await;
let h = holder();
let body = serde_json::to_vec(&json!({ "requestId": "urn:uuid:bare" })).unwrap();
for ctx in [
JoinAuthCtx::rest(),
JoinAuthCtx::didcomm(h.did.clone()),
JoinAuthCtx {
transport: JoinTransport::Tsp,
sender_did: Some(h.did.clone()),
verified_signer: None,
},
] {
let transport = ctx.transport.as_str();
let out = dispatch_trust_task_core(&tv.state, &ctx, &body).await;
let doc: Value = serde_json::from_slice(&out.body).expect("an error document");
assert_eq!(
error_code(&out).as_deref(),
Some("malformedRequest"),
"{transport}: {doc}"
);
assert!(
doc.pointer("/payload/message")
.and_then(Value::as_str)
.is_some_and(|m| m.contains("missing field `id`")),
"{transport}: {doc}"
);
}
}
#[test]
fn every_rooms_task_declares_the_proof_its_arm_requires() {
for uri in vti_rooms::wire::ROOMS_DISPATCHED_URIS {
let policy = trust_tasks_rs::schema_index::spec_policy_for(uri)
.unwrap_or_else(|| panic!("{uri} has no published spec policy"));
assert!(
policy.is_proof_required,
"{uri}: its arm refuses without a verified signer, so the \
specification had better agree that one is required"
);
}
}
const DISPATCHED_WITHOUT_PROOF: &[(&str, &str)] = &[
(
jr::JOIN_REQUEST_MANIFEST_TYPE,
"a public read of the community's join criteria; names no identity to authorize",
),
(
vetting_wire::VETTING_VETTER_LIST_TYPE,
"a public read of the vetter registry",
),
(
role_tasks::LIST_TYPE,
"reads the role vocabulary; the handler authorizes from the signer's own entry, so an unsigned caller is refused regardless",
),
(
role_tasks::SHOW_TYPE,
"reads one role; the handler authorizes from the signer's own entry, so an unsigned caller is refused regardless",
),
(
storage_tasks::LIST_TYPE,
"reads the storage configs; the handler authorizes vtc.rooms.admin from the signer's own entry, so an unsigned caller is refused regardless",
),
(
storage_tasks::GET_TYPE,
"reads one storage config; the handler authorizes vtc.rooms.admin from the signer's own entry, so an unsigned caller is refused regardless",
),
(
storage_tasks::PROBE_TYPE,
"probes one storage config; the handler authorizes vtc.rooms.admin from the signer's own entry, so an unsigned caller is refused regardless",
),
(
room_admin_tasks::GET_TYPE,
"reads one room's storage, limits and usage; the handler authorizes vtc.rooms.admin from the signer's own entry, so an unsigned caller is refused regardless",
),
(
community_room_tasks::LIST_TYPE,
"lists the rooms the community owns; the handler authorizes vtc.rooms.manage from the signer's own entry, so an unsigned caller is refused regardless",
),
(
community_room_tasks::GET_TYPE,
"reads one room the community owns; the handler authorizes vtc.rooms.manage from the signer's own entry, so an unsigned caller is refused regardless",
),
(
community_room_tasks::LIST_V0_2_TYPE,
"lists the rooms the community owns, with their details; the handler authorizes vtc.rooms.manage from the signer's own entry, so an unsigned caller is refused regardless",
),
(
community_room_tasks::GET_V0_2_TYPE,
"reads one room the community owns, with its details; the handler authorizes vtc.rooms.manage from the signer's own entry, so an unsigned caller is refused regardless",
),
(
room_admin_tasks::USAGE_TYPE,
"reads rooms' usage; the handler authorizes vtc.rooms.admin from the signer's own entry, so an unsigned caller is refused regardless",
),
(
vetting_wire::VETTING_VETTER_SHOW_TYPE,
"a public read of the vetter registry",
),
(
admin_tasks::AUDIT_VERIFY_TYPE,
"a read; the handler's admin bearer/ACL check refuses an unsigned caller regardless",
),
(
install_tasks::CLAIM_START_TYPE,
"first-admin onboarding, before any ACL entry exists to sign with; the one-time install token the handler requires is the credential",
),
(
install_tasks::CLAIM_FINISH_TYPE,
"first-admin onboarding, before any ACL entry exists to sign with; the one-time install token the handler requires is the credential",
),
(
install_tasks::CLAIM_START_V0_3_TYPE,
"opens a claim and changes nothing; the install token and its claim code are the gate, and control of the founder's DID is proven by the finish, whose proof the specification requires",
),
(
install_tasks::BOOTSTRAP_TYPE,
"first-admin onboarding, before any ACL entry exists to sign with; the one-time install token the handler requires is the credential",
),
(
recognise_tasks::CHALLENGE_TYPE,
"names no identity to authorize — it is the first message of the cross-community handshake",
),
(
recognise_tasks::RECOGNISE_TYPE,
"the peer VTC's answer is authenticated via the Trust Registry lookup the handler performs, not a request-level proof",
),
(
discovery::DISCOVERY_V0_3_TYPE,
"a public discovery read; names no identity to authorize",
),
(
ACL_SHOW_TYPE,
"reads the caller's own ACL row; the handler authorizes from the signer's entry, so an unsigned caller is refused regardless",
),
(
ACL_LIST_TYPE,
"reads ACL rows; the handler authorizes from the signer's own entry, so an unsigned caller is refused regardless",
),
(
ACL_SHOW_V0_2_TYPE,
"reads an ACL row; the handler authorizes from the signer's own entry, so an unsigned caller is refused regardless",
),
(
ACL_LIST_V0_2_TYPE,
"reads ACL rows; the handler authorizes from the signer's own entry, so an unsigned caller is refused regardless",
),
(
member_tasks::RELATIONSHIPS_LIST_TYPE,
"a read; the handler refuses an unsigned caller regardless",
),
(
member_tasks::ENDORSEMENTS_LIST_TYPE,
"a read; the handler refuses an unsigned caller regardless",
),
(
member_tasks::ENDORSEMENTS_SHOW_TYPE,
"a read; the handler refuses an unsigned caller regardless",
),
(
STEP_UP_APPROVE_RESPONSE_TYPE,
"its gate is the WebAuthn assertion it carries — the handler still requires the approver's assertionMethod proof via `verify_approval_proof`, which is not the request-level `proof` this flag governs",
),
(
step_up_passkey_tasks::REDEEM_START_TYPE,
"redeems a single-use invite token; the handler requires a proof regardless (VTI-APV passkey redemption), the token itself being the thing that makes an unsigned attempt refusable",
),
(
step_up_passkey_tasks::REDEEM_FINISH_TYPE,
"redeems a single-use invite token; the handler requires a proof regardless (VTI-APV passkey redemption), the token itself being the thing that makes an unsigned attempt refusable",
),
(
admin_tasks::DIAGNOSTICS_TYPE,
"a read; the handler's admin bearer/ACL check refuses an unsigned caller regardless",
),
(
admin_tasks::SYNC_JOBS_LIST_TYPE,
"a read; the handler's admin bearer/ACL check refuses an unsigned caller regardless",
),
(
admin_tasks::RECORDS_LIST_TYPE,
"a read; the handler's admin bearer/ACL check refuses an unsigned caller regardless",
),
(
admin_tasks::CONFIG_SHOW_TYPE,
"a read; the handler's admin bearer/ACL check refuses an unsigned caller regardless",
),
(
admin_tasks::INVITES_LIST_TYPE,
"a read; the handler's admin bearer/ACL check refuses an unsigned caller regardless",
),
(
community_tasks::PROFILE_SHOW_TYPE,
"a public/community read; the handler refuses an unsigned caller where that read is gated at all",
),
(
community_tasks::CEREMONIES_LIST_TYPE,
"a community read; the handler refuses an unsigned caller where that read is gated at all",
),
(
community_tasks::DIRECTORY_QUERY_TYPE,
"a community read; the handler refuses an unsigned caller where that read is gated at all",
),
(
directory_tasks::FIELDS_LIST_TYPE,
"reads the directory schema; the handler authorizes from the signer's own standing, so an unsigned caller is refused regardless",
),
(
directory_tasks::CARD_SHOW_TYPE,
"reads the signer's own card; the handler matches the signer to their membership, so an unsigned caller is refused regardless",
),
(
directory_tasks::ENTRIES_LIST_TYPE,
"reads the directory projected for the signer; the handler authorizes from the signer's own standing, so an unsigned caller is refused regardless",
),
(
directory_tasks::ENTRIES_SHOW_TYPE,
"reads one entry projected for the signer; the handler authorizes from the signer's own standing, so an unsigned caller is refused regardless",
),
(
community_tasks::ENDORSEMENT_TYPES_LIST_TYPE,
"a community read; the handler refuses an unsigned caller where that read is gated at all",
),
(
community_tasks::RECOGNITION_CHECK_TYPE,
"a community read; the handler refuses an unsigned caller where that read is gated at all",
),
(
community_tasks::MEMBERS_LIST_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
community_tasks::MEMBERS_REMOVED_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
community_tasks::MEMBERS_SHOW_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
community_tasks::MEMBERS_SOLICIT_VMC_TYPE,
"an admin action; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
community_tasks::JOIN_REQUESTS_LIST_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
community_tasks::JOIN_REQUESTS_SHOW_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
community_tasks::RELATIONSHIPS_GRAPH_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
community_tasks::INVITATIONS_LIST_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
policy_tasks::POLICY_LIST_TYPE,
"a read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
policy_tasks::POLICY_GET_TYPE,
"a read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
policy_tasks::POLICY_ACTIVE_TYPE,
"a read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
policy_tasks::POLICY_TEST_TYPE,
"a dry-run read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
policy_tasks::DID_REGISTER_TYPE,
"declares no request-level proof as a specification matter; the handler's own authorization refuses an unsigned caller regardless",
),
(surface_tasks::BRANDING_SHOW_TYPE, "a public read"),
(
surface_tasks::THEME_SHOW_TYPE,
"a public read, answered to an unidentified caller too: the sign-in pages draw the theme before the visitor has an identity",
),
(surface_tasks::REQUESTED_SHOW_TYPE, "a public read"),
(surface_tasks::JOIN_DISCOVERY_SHOW_TYPE, "a public read"),
(surface_tasks::SCHEMAS_LIST_TYPE, "a public read"),
(surface_tasks::SCHEMAS_SHOW_TYPE, "a public read"),
(surface_tasks::ACCEPTS_LIST_TYPE, "a public read"),
(surface_tasks::ACCEPTS_SHOW_TYPE, "a public read"),
(
surface_tasks::VETTER_GRANTS_LIST_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
surface_tasks::AUTO_GRANT_SHOW_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
surface_tasks::REVOCATIONS_LIST_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
surface_tasks::JOIN_VETTING_SHOW_TYPE,
"an admin read; the handler's admin ACL check refuses an unsigned caller regardless",
),
(
surface_tasks::ROOMS_LIST_TYPE,
"lists the hosted rooms; the handler authorizes vtc.rooms.admin from the signer's own entry, so an unsigned caller is refused regardless",
),
(
capability_module_tasks::LIST_TYPE,
"a member read; `acting_party` refuses an unsigned caller as `proofRequired`",
),
(
auth_tasks::CHALLENGE_TYPE,
"names no identity to authorize — it is the first message of the handshake",
),
(
auth_tasks::REFRESH_V0_2_TYPE,
"the refresh token itself is the credential, not a request-level proof",
),
#[cfg(feature = "website")]
(website_tasks::FILES_SHOW_TYPE, "a public read"),
#[cfg(feature = "website")]
(
website_tasks::FILES_LIST_TYPE,
"an admin read; `admin_signer` refuses an unsigned caller regardless",
),
#[cfg(feature = "website")]
(
website_tasks::GENERATIONS_LIST_TYPE,
"an admin read; `admin_signer` refuses an unsigned caller regardless",
),
];
#[test]
fn the_dispatched_set_declares_the_proofs_the_design_note_records() {
let dispatched: Vec<&str> = DISPATCHED_URIS
.iter()
.copied()
.chain(crate::rooms::handlers::served_uris())
.collect();
let mut unexplained = Vec::new();
let mut stale = Vec::new();
for &uri in &dispatched {
let required = spec_policy_for(uri).is_some_and(|p| p.is_proof_required);
let allow_listed = DISPATCHED_WITHOUT_PROOF.iter().any(|&(u, _)| u == uri);
match (required, allow_listed) {
(false, false) => unexplained.push(uri),
(true, true) => stale.push(uri),
_ => {}
}
}
assert!(
unexplained.is_empty(),
"dispatched, no proof required by spec, and not reasoned about in \
DISPATCHED_WITHOUT_PROOF — either its specification should require a proof, or \
add an entry there with the reason an unsigned caller is refused some other way: \
{unexplained:#?}"
);
assert!(
stale.is_empty(),
"DISPATCHED_WITHOUT_PROOF entries whose specification now requires a proof — \
remove the stale entries: {stale:#?}"
);
for &(uri, reason) in DISPATCHED_WITHOUT_PROOF {
assert!(
dispatched.contains(&uri),
"`{uri}` is in DISPATCHED_WITHOUT_PROOF ({reason}) but is not dispatched any \
more — remove the stale entry"
);
}
}
fn peer(purpose_code: char, seed: u8) -> (String, affinidi_secrets_resolver::secrets::Secret) {
use affinidi_secrets_resolver::secrets::Secret;
let probe = Secret::generate_ed25519(None, Some(&[seed; 32]));
let mb = probe.get_public_keymultibase().expect("public key");
let did = format!("did:peer:2.{purpose_code}{mb}");
let secret = Secret::generate_ed25519(Some(&format!("{did}#key-1")), Some(&[seed; 32]));
(did, secret)
}
fn key_signer(seed: u8) -> (String, affinidi_secrets_resolver::secrets::Secret) {
use affinidi_secrets_resolver::secrets::Secret;
let mut secret = Secret::generate_ed25519(None, Some(&[seed; 32]));
let mb = secret.get_public_keymultibase().expect("public key");
let did = format!("did:key:{mb}");
secret.id = format!("{did}#{mb}");
(did, secret)
}
async fn approval(
uri: &str,
payload: Value,
issuer: &str,
secret: &affinidi_secrets_resolver::secrets::Secret,
purpose: &str,
) -> TrustTask<Value> {
use affinidi_data_integrity::{DataIntegrityProof, SignOptions};
let doc = vta_sdk::trust_task_sign::build_unsigned(uri, payload, issuer, TEST_VTC_DID)
.expect("build the document");
let mut doc = serde_json::to_value(doc).unwrap();
doc.as_object_mut().unwrap().remove("proof");
let proof =
DataIntegrityProof::sign(&doc, secret, SignOptions::new().with_proof_purpose(purpose))
.await
.expect("sign the approval");
doc["proof"] = serde_json::to_value(proof).unwrap();
serde_json::from_value(doc).unwrap()
}
fn decision_payload() -> Value {
json!({
"challenge": "9c1f4b7a2e6d80f35a4c9b1e7d2f6083",
"payloadDigest": "zQmSK9pGKFnmc77pqyNAPJyPKt8rMqctngfg3vwuMArwGYZ",
"decision": "approve",
})
}
fn approve_response_payload(subject: &str) -> Value {
json!({
"subject": subject,
"challenge": "c2VjcmV0LWNoYWxsZW5nZS12YWx1ZQ",
"decision": "approved",
})
}
fn refused_at_the_proof(out: &TrustTaskOutcome) -> bool {
let body = String::from_utf8_lossy(&out.body);
error_code(out).as_deref() == Some("permissionDenied") && body.contains("proof")
}
fn sign_received(doc: &mut Value, seed: u8, vm: &str, purpose: &str, created: &str) {
use affinidi_data_integrity::crypto_suites::CryptoSuite;
use affinidi_data_integrity::{DataIntegrityProof, prepare_sign_input};
use ed25519_dalek::Signer as _;
let mut di = DataIntegrityProof::new(
CryptoSuite::EddsaJcs2022,
vm.to_string(),
purpose.to_string(),
None,
Some(created.to_string()),
None,
);
doc.as_object_mut().expect("an object").remove("proof");
let input = prepare_sign_input(&*doc, &di, CryptoSuite::EddsaJcs2022).expect("sign input");
let sk = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]);
di.proof_value = Some(multibase::encode(
multibase::Base::Base58Btc,
sk.sign(&input).to_bytes(),
));
doc["proof"] = serde_json::to_value(&di).expect("proof serialises");
}
fn javascript_whole_second_now() -> String {
format!("{}.000Z", chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S"))
}
fn vti_45_document(
uri: &str,
payload: Value,
issuer: &str,
seed: u8,
vm: &str,
purpose: &str,
) -> Value {
let now = javascript_whole_second_now();
let mut doc = json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": uri,
"issuer": issuer,
"recipient": TEST_VTC_DID,
"issuedAt": now,
"threadId": null,
"payload": payload,
});
sign_received(&mut doc, seed, vm, purpose, &now);
doc
}
async fn dispatch_received(state: &AppState, doc: &Value) -> TrustTaskOutcome {
let body = serde_json::to_vec(doc).expect("a document serialises");
dispatch_trust_task_core(state, &JoinAuthCtx::rest(), &body).await
}
#[tokio::test]
async fn vti_45_a_javascript_whole_second_document_passes_the_spine() {
let tv = build_test_vtc().await;
let (did, secret) = key_signer(0x45);
let doc = vti_45_document(
UNDER_TEST,
json!({}),
&did,
0x45,
&secret.id,
"assertionMethod",
);
let typed: TrustTask<Value> = serde_json::from_value(doc.clone()).unwrap();
assert!(
vti_common::auth::verify_trust_task_proof(&typed)
.await
.is_err(),
"the typed form must be what this document breaks"
);
let out = dispatch_received(&tv.state, &doc).await;
for code in ["proofRequired", "proofInvalid"] {
assert_ne!(
error_code(&out).as_deref(),
Some(code),
"{}",
String::from_utf8_lossy(&out.body)
);
}
let mut tampered = doc;
tampered["payload"] = json!({ "requestId": "someone-elses" });
let out = dispatch_received(&tv.state, &tampered).await;
assert!(
refused_at_the_proof(&out),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn vti_45_a_javascript_whole_second_approval_is_held_to_its_purpose() {
let tv = build_test_vtc().await;
let (did, secret) = peer('A', 0x46);
for uri in [
crate::acl::admin_consent::DECISION_TYPE,
STEP_UP_APPROVE_RESPONSE_TYPE,
] {
let payload = if uri == STEP_UP_APPROVE_RESPONSE_TYPE {
approve_response_payload(&did)
} else {
decision_payload()
};
let ok = vti_45_document(
uri,
payload.clone(),
&did,
0x46,
&secret.id,
"assertionMethod",
);
let out = dispatch_received(&tv.state, &ok).await;
assert!(
!refused_at_the_proof(&out),
"{uri}: {}",
String::from_utf8_lossy(&out.body)
);
let operational =
vti_45_document(uri, payload, &did, 0x46, &secret.id, "authentication");
let out = dispatch_received(&tv.state, &operational).await;
assert!(
refused_at_the_proof(&out),
"{uri}: {}",
String::from_utf8_lossy(&out.body)
);
}
}
#[tokio::test]
async fn an_approval_signed_for_authentication_is_refused() {
let tv = build_test_vtc().await;
let (did, secret) = key_signer(0x51);
for (uri, payload) in [
(crate::acl::admin_consent::DECISION_TYPE, decision_payload()),
(
STEP_UP_APPROVE_RESPONSE_TYPE,
approve_response_payload(&did),
),
(
STEP_UP_APPROVE_RESPONSE_V0_5_TYPE,
approve_response_payload(&did),
),
] {
let doc = approval(uri, payload, &did, &secret, "authentication").await;
let out = dispatch(&tv.state, &doc).await;
let body = String::from_utf8_lossy(&out.body);
assert!(refused_at_the_proof(&out), "{uri}: {body}");
assert!(body.contains("assertionMethod"), "{uri}: {body}");
}
}
#[tokio::test]
async fn an_approval_by_a_key_not_listed_under_assertion_method_is_refused() {
let tv = build_test_vtc().await;
let (did, secret) = peer('D', 0x52);
for (uri, payload) in [
(crate::acl::admin_consent::DECISION_TYPE, decision_payload()),
(
STEP_UP_APPROVE_RESPONSE_TYPE,
approve_response_payload(&did),
),
(
STEP_UP_APPROVE_RESPONSE_V0_5_TYPE,
approve_response_payload(&did),
),
] {
let doc = approval(uri, payload, &did, &secret, "assertionMethod").await;
let err = vti_common::auth::verify_trust_task_proof(&doc)
.await
.unwrap_err();
assert!(
err.cause().is_some_and(|c| c.contains("assertionMethod")),
"{uri}: {err:?}"
);
let out = dispatch(&tv.state, &doc).await;
assert!(
refused_at_the_proof(&out),
"{uri}: {}",
String::from_utf8_lossy(&out.body)
);
}
}
#[tokio::test]
async fn an_approval_whose_signer_does_not_resolve_is_refused() {
use affinidi_secrets_resolver::secrets::Secret;
let tv = build_test_vtc().await;
let did = "did:web:approver.invalid";
let secret = Secret::generate_ed25519(Some(&format!("{did}#key-1")), Some(&[0x53; 32]));
for (uri, payload) in [
(crate::acl::admin_consent::DECISION_TYPE, decision_payload()),
(STEP_UP_APPROVE_RESPONSE_TYPE, approve_response_payload(did)),
(
STEP_UP_APPROVE_RESPONSE_V0_5_TYPE,
approve_response_payload(did),
),
] {
let doc = approval(uri, payload, did, &secret, "assertionMethod").await;
let out = dispatch(&tv.state, &doc).await;
assert!(
refused_at_the_proof(&out),
"{uri}: {}",
String::from_utf8_lossy(&out.body)
);
}
}
#[tokio::test]
async fn an_assertion_method_approval_passes_the_proof() {
let tv = build_test_vtc().await;
let signers = [key_signer(0x54), peer('A', 0x55)];
for (did, secret) in &signers {
for (uri, payload) in [
(crate::acl::admin_consent::DECISION_TYPE, decision_payload()),
(STEP_UP_APPROVE_RESPONSE_TYPE, approve_response_payload(did)),
(
STEP_UP_APPROVE_RESPONSE_V0_5_TYPE,
approve_response_payload(did),
),
] {
let doc = approval(uri, payload, did, secret, "assertionMethod").await;
let out = dispatch(&tv.state, &doc).await;
let body = String::from_utf8_lossy(&out.body);
assert!(
!body.contains("proof"),
"{uri} by {did} passes the proof check: {body}"
);
}
}
}
#[tokio::test]
async fn an_unsigned_approve_response_is_refused() {
let tv = build_test_vtc().await;
let h = holder();
for uri in [
STEP_UP_APPROVE_RESPONSE_TYPE,
STEP_UP_APPROVE_RESPONSE_V0_5_TYPE,
] {
let doc = unsigned(&h, uri, approve_response_payload(&h.did));
let out = dispatch(&tv.state, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("proofRequired"),
"{uri}: {}",
String::from_utf8_lossy(&out.body)
);
}
}
#[test]
fn the_0_5_payload_schema_is_wire_identical_to_0_4() {
let filled = json!({
"subject": "did:key:zAlice",
"challenge": "Y2hhbGxlbmdlLWNoYWxsZW5nZS1jaGFsbGVuZ2U",
"sessionId": "9c2e1f7a-6b3d-4c8e-9a1b-2d3e4f5a6b7c",
"decision": "approved",
"grantedAcr": "aal2",
"evidence": {
"kind": "webauthn",
"assertion": {
"id": "Y3JlZF8xYTJiM2M",
"rawId": "Y3JlZF8xYTJiM2M",
"type": "public-key",
"response": {
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0In0",
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAAAQ",
"signature": "MEUCIQDx",
},
},
},
});
let v5: step_up_approve_response_v0_5::Payload =
serde_json::from_value(filled.clone()).expect("a 0.5 payload parses");
let v4: step_up_approve_response::Payload = serde_json::to_value(&v5)
.and_then(serde_json::from_value)
.unwrap_or_else(|e| {
panic!("0.5 payload does not convert to 0.4's shape: {e}\n{filled:#}")
});
assert_eq!(
serde_json::to_value(&v4).unwrap(),
filled,
"round-tripping through 0.4's type must not drop or rename a member"
);
}
}
fn task_family(type_uri: &str) -> Option<&str> {
type_uri.rsplit_once('/').map(|(family, _version)| family)
}
fn unsupported_type_or_version(doc: &TrustTask<Value>, type_uri: &str) -> TrustTaskOutcome {
use trust_tasks_rs::{StandardCode, TrustTaskCode};
use vta_sdk::protocols::trust_task_reject_details as details;
let family = task_family(type_uri);
let mut served: Vec<&str> = DISPATCHED_URIS
.iter()
.copied()
.chain(crate::rooms::handlers::served_uris())
.chain(crate::git_ns::tasks::served_uris())
.filter(|uri| family.is_some() && task_family(uri) == family)
.collect();
served.sort_unstable();
served.dedup();
if served.is_empty() {
return reject_with_code(
doc,
TrustTaskCode::Standard(StandardCode::UnsupportedType),
format!("unsupported type: {type_uri}"),
Some(serde_json::json!({ details::REQUESTED_TYPE: type_uri })),
);
}
reject_with_code(
doc,
TrustTaskCode::Standard(StandardCode::UnsupportedVersion),
format!(
"unsupported version: {type_uri} — this VTC serves {}",
served.join(", ")
),
Some(serde_json::json!({
details::REQUESTED_TYPE: type_uri,
details::SERVED_VERSIONS: served,
})),
)
}
pub(crate) const DISPATCHED_URIS: &[&str] = &[
jr::JOIN_REQUEST_SUBMIT_TYPE,
jr::JOIN_REQUEST_MANIFEST_TYPE,
jr::JOIN_REQUEST_STATUS_TYPE,
jr::JOIN_REQUEST_WITHDRAW_TYPE,
jr::JOIN_REQUEST_SUPPLEMENT_TYPE,
jr::JOIN_REQUEST_WALLET_SESSION_START_TYPE,
jr::JOIN_REQUEST_WALLET_SESSION_STATUS_TYPE,
jr::MEMBER_SELF_REMOVE_TYPE,
mem::MEMBER_VMC_TYPE,
vta_sdk::protocols::credential_exchange::REQUEST,
vta_sdk::protocols::credential_exchange::PRESENT,
vetting_wire::VETTING_REVOKE_STATEMENT_TYPE,
vetting_wire::VETTING_VETTER_GRANT_TYPE,
vetting_wire::VETTING_VETTER_PROFILE_TYPE,
vetting_wire::VETTING_VETTER_LIST_TYPE,
vetting_wire::VETTING_VETTER_SHOW_TYPE,
vetting_wire::VETTING_VETTER_RESEND_TYPE,
vetting_wire::VETTING_VETTER_RESEND_0_2_TYPE,
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::PCS_ROOT_TYPE,
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::PCS_TOKENS_TYPE,
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::EVENT_MODE_TYPE,
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::PCS_CHALLENGE_TYPE,
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::HIDDEN_PUBLISH_TYPE,
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::HIDDEN_WITHDRAW_TYPE,
#[cfg(feature = "vetting-pcs")]
crate::vetting::pcs_tasks::HIDDEN_SHOW_TYPE,
PERSONHOOD_CHALLENGE_TYPE,
PERSONHOOD_ASSERT_TYPE,
MEMBER_CREDENTIALS_TYPE,
MEMBER_UPDATE_TYPE,
MEMBER_ADMIN_REMOVE_TYPE,
MEMBER_PURGE_TYPE,
admission_tasks::ADMIT_TYPE,
admission_tasks::MAKE_MEMBERSHIP_TYPE,
JOIN_DECIDE_TYPE,
COMMUNITY_PROFILE_UPDATE_TYPE,
CONFIG_EXPORT_TYPE,
CONFIG_IMPORT_TYPE,
ENDORSEMENT_TYPE_REGISTER_TYPE,
ENDORSEMENT_TYPE_DELETE_TYPE,
BACKUP_EXPORT_TYPE,
ACL_GRANT_TYPE,
ACL_CHANGE_ROLE_TYPE,
ACL_SHOW_TYPE,
ACL_LIST_TYPE,
ACL_UPDATE_TYPE,
ACL_REVOKE_TYPE,
ACL_GRANT_V0_2_TYPE,
ACL_CHANGE_ROLE_V0_2_TYPE,
ACL_SHOW_V0_2_TYPE,
ACL_LIST_V0_2_TYPE,
ACL_UPDATE_V0_2_TYPE,
ACL_REVOKE_V0_2_TYPE,
STEP_UP_APPROVE_RESPONSE_TYPE,
STEP_UP_APPROVE_RESPONSE_V0_5_TYPE,
STEP_UP_APPROVE_RESPONSE_V0_6_TYPE,
crate::acl::admin_consent::DECISION_TYPE,
crate::acl::admin_consent::DECISION_V0_2_TYPE,
action_tasks::LIST_TYPE,
action_tasks::SHOW_TYPE,
action_tasks::CANCEL_TYPE,
action_tasks::ACKNOWLEDGE_TYPE,
action_tasks::LIST_V0_2_TYPE,
action_tasks::SHOW_V0_2_TYPE,
action_tasks::CANCEL_V0_2_TYPE,
action_tasks::ACKNOWLEDGE_V0_2_TYPE,
event_tasks::SUBSCRIBE_TYPE,
event_tasks::SUBSCRIBE_V0_2_TYPE,
step_up_passkey_tasks::INVITE_TYPE,
step_up_passkey_tasks::REDEEM_START_TYPE,
step_up_passkey_tasks::REDEEM_FINISH_TYPE,
step_up_passkey_tasks::REVOKE_START_TYPE,
step_up_passkey_tasks::REVOKE_FINISH_TYPE,
step_up_passkey_tasks::ADMIN_LIST_TYPE,
step_up_approver_tasks::INVITE_TYPE,
step_up_approver_tasks::REDEEM_START_TYPE,
step_up_approver_tasks::REDEEM_FINISH_TYPE,
step_up_approver_tasks::ENROLL_TYPE,
step_up_approver_tasks::LIST_TYPE,
step_up_approver_tasks::REVOKE_TYPE,
role_tasks::DEFINE_TYPE,
role_tasks::LIST_TYPE,
role_tasks::SHOW_TYPE,
role_tasks::DELETE_TYPE,
storage_tasks::LIST_TYPE,
storage_tasks::GET_TYPE,
storage_tasks::CREATE_TYPE,
storage_tasks::UPDATE_TYPE,
storage_tasks::RETIRE_TYPE,
storage_tasks::PROBE_TYPE,
storage_tasks::ASSIGN_TYPE,
room_admin_tasks::GET_TYPE,
room_admin_tasks::USAGE_TYPE,
room_admin_tasks::LIMITS_SET_TYPE,
community_room_tasks::CREATE_TYPE,
community_room_tasks::CREATE_V0_2_TYPE,
community_room_tasks::CREATE_V0_3_TYPE,
community_room_tasks::LIST_TYPE,
community_room_tasks::LIST_V0_2_TYPE,
community_room_tasks::GET_TYPE,
community_room_tasks::GET_V0_2_TYPE,
community_room_tasks::UPDATE_TYPE,
community_room_tasks::UPDATE_V0_2_TYPE,
community_room_tasks::MEMBERS_ADD_TYPE,
community_room_tasks::MEMBERS_REMOVE_TYPE,
community_room_tasks::MEMBERS_REMOVE_V0_2_TYPE,
community_room_tasks::MEMBERS_LIST_TYPE,
community_room_tasks::OFFERS_LIST_TYPE,
community_room_tasks::OFFERS_LIST_V0_2_TYPE,
community_room_tasks::OFFERS_ACCEPT_TYPE,
community_room_tasks::MEMBERSHIPS_LIST_TYPE,
oid4vp_tasks::IDENTITIES_LIST_TYPE,
ACL_SWAP_KEY_TYPE,
capability_module_tasks::LIST_TYPE,
capability_module_tasks::ENABLE_TYPE,
capability_module_tasks::DISABLE_TYPE,
backup_tasks::INITIATE_EXPORT_TYPE,
backup_tasks::GET_CHUNK_TYPE,
backup_tasks::COMPLETE_EXPORT_TYPE,
backup_tasks::INITIATE_IMPORT_TYPE,
backup_tasks::PUT_CHUNK_TYPE,
backup_tasks::FINALIZE_IMPORT_TYPE,
backup_tasks::ABORT_TYPE,
member_tasks::RENEW_TYPE,
member_tasks::ROTATE_CHALLENGE_TYPE,
member_tasks::ROTATE_TYPE,
member_tasks::PERSONHOOD_REVOKE_TYPE,
member_tasks::RELATIONSHIPS_LIST_TYPE,
member_tasks::RELATIONSHIPS_PUBLISH_TYPE,
member_tasks::RELATIONSHIPS_REVOKE_TYPE,
member_tasks::RELATIONSHIPS_REVOKE_0_2_TYPE,
member_tasks::ENDORSEMENTS_ISSUE_TYPE,
member_tasks::ENDORSEMENTS_LIST_TYPE,
member_tasks::ENDORSEMENTS_SHOW_TYPE,
member_tasks::ENDORSEMENTS_REVOKE_TYPE,
admin_tasks::DIAGNOSTICS_TYPE,
admin_tasks::SYNC_JOBS_LIST_TYPE,
admin_tasks::SYNC_JOBS_RETRY_TYPE,
admin_tasks::SYNC_JOBS_RETRY_0_2_TYPE,
admin_tasks::SYNC_JOBS_DISCARD_TYPE,
admin_tasks::RECORDS_LIST_TYPE,
admin_tasks::AUDIT_LIST_TYPE,
admin_tasks::AUDIT_VERIFY_TYPE,
admin_tasks::CONFIG_SHOW_TYPE,
admin_tasks::CONFIG_PATCH_TYPE,
admin_tasks::CONFIG_RELOAD_TYPE,
admin_tasks::CONFIG_RESTART_TYPE,
admin_tasks::INVITES_LIST_TYPE,
admin_tasks::INVITES_CREATE_TYPE,
admin_tasks::INVITES_REVOKE_TYPE,
admin_tasks::SESSIONS_LIST_TYPE,
admin_tasks::REVOKE_SESSION_TYPE,
crate::member_portal::oob::types::REQUEST_TYPE,
crate::member_portal::oob::types::CLAIM_TYPE,
crate::member_portal::oob::types::PROVE_TYPE,
crate::member_portal::oob::types::RESPOND_TYPE,
crate::member_portal::oob::types::REDEEM_TYPE,
crate::member_portal::oob::types::CANCEL_TYPE,
auth_tasks::CHALLENGE_TYPE,
auth_tasks::AUTHENTICATE_V0_2_TYPE,
auth_tasks::AUTHENTICATE_V0_3_TYPE,
auth_tasks::REFRESH_V0_2_TYPE,
install_tasks::CLAIM_START_TYPE,
install_tasks::CLAIM_FINISH_TYPE,
install_tasks::CLAIM_START_V0_3_TYPE,
install_tasks::CLAIM_FINISH_V0_3_TYPE,
install_tasks::BOOTSTRAP_TYPE,
recognise_tasks::CHALLENGE_TYPE,
recognise_tasks::RECOGNISE_TYPE,
community_tasks::PROFILE_SHOW_TYPE,
community_tasks::CEREMONIES_LIST_TYPE,
community_tasks::DIRECTORY_QUERY_TYPE,
directory_tasks::FIELDS_LIST_TYPE,
directory_tasks::FIELDS_DEFINE_TYPE,
directory_tasks::FIELDS_UPDATE_TYPE,
directory_tasks::FIELDS_RETIRE_TYPE,
directory_tasks::CARD_SHOW_TYPE,
directory_tasks::CARD_UPDATE_TYPE,
directory_tasks::CARD_ASSERT_TYPE,
directory_tasks::ENTRIES_LIST_TYPE,
directory_tasks::ENTRIES_SHOW_TYPE,
community_tasks::ENDORSEMENT_TYPES_LIST_TYPE,
community_tasks::RECOGNITION_CHECK_TYPE,
community_tasks::MEMBERS_LIST_TYPE,
community_tasks::MEMBERS_REMOVED_TYPE,
community_tasks::MEMBERS_SHOW_TYPE,
community_tasks::MEMBERS_SOLICIT_VMC_TYPE,
community_tasks::JOIN_REQUESTS_LIST_TYPE,
community_tasks::JOIN_REQUESTS_SHOW_TYPE,
community_tasks::RELATIONSHIPS_GRAPH_TYPE,
community_tasks::INVITATIONS_ISSUE_TYPE,
community_tasks::INVITATIONS_LIST_TYPE,
community_tasks::INVITATIONS_REVOKE_TYPE,
community_tasks::INVITATIONS_DELIVER_TYPE,
policy_tasks::POLICY_LIST_TYPE,
policy_tasks::POLICY_GET_TYPE,
policy_tasks::POLICY_ACTIVE_TYPE,
policy_tasks::POLICY_UPSERT_TYPE,
policy_tasks::POLICY_ACTIVATE_TYPE,
policy_tasks::POLICY_TEST_TYPE,
policy_tasks::DID_REGISTER_TYPE,
surface_tasks::BRANDING_SHOW_TYPE,
surface_tasks::BRANDING_UPDATE_TYPE,
surface_tasks::THEME_SHOW_TYPE,
surface_tasks::THEME_UPDATE_TYPE,
surface_tasks::REQUESTED_SHOW_TYPE,
surface_tasks::REQUESTED_UPDATE_TYPE,
surface_tasks::JOIN_DISCOVERY_SHOW_TYPE,
surface_tasks::JOIN_DISCOVERY_UPDATE_TYPE,
surface_tasks::SCHEMAS_REGISTER_TYPE,
surface_tasks::SCHEMAS_LIST_TYPE,
surface_tasks::SCHEMAS_SHOW_TYPE,
surface_tasks::SCHEMAS_DELETE_TYPE,
surface_tasks::ACCEPTS_REGISTER_TYPE,
surface_tasks::ACCEPTS_LIST_TYPE,
surface_tasks::ACCEPTS_SHOW_TYPE,
surface_tasks::ACCEPTS_DELETE_TYPE,
surface_tasks::VETTER_GRANTS_LIST_TYPE,
surface_tasks::AUTO_GRANT_SHOW_TYPE,
surface_tasks::AUTO_GRANT_UPDATE_TYPE,
surface_tasks::REVOCATIONS_LIST_TYPE,
surface_tasks::RELATIONSHIPS_SUSPEND_TYPE,
surface_tasks::RELATIONSHIPS_RESTORE_TYPE,
surface_tasks::JOIN_VETTING_SHOW_TYPE,
surface_tasks::JOIN_QUERY_TYPE,
surface_tasks::ROOMS_LIST_TYPE,
signing_key_tasks::ENROLL_TYPE,
signing_key_tasks::ENROLL_V0_2_TYPE,
signing_key_tasks::LIST_TYPE,
signing_key_tasks::REVOKE_TYPE,
admin_passkey_tasks::LIST_TYPE,
admin_passkey_tasks::ENROLL_START_TYPE,
admin_passkey_tasks::ENROLL_FINISH_TYPE,
admin_passkey_tasks::REVOKE_START_TYPE,
admin_passkey_tasks::REVOKE_FINISH_TYPE,
admin_session_tasks::ADMIN_SESSION_TYPE,
whoami_tasks::WHOAMI_TYPE,
#[cfg(feature = "website")]
website_tasks::BEGIN_TYPE,
#[cfg(feature = "website")]
website_tasks::CHUNK_TYPE,
#[cfg(feature = "website")]
website_tasks::COMMIT_TYPE,
#[cfg(feature = "website")]
website_tasks::ABORT_TYPE,
#[cfg(feature = "website")]
website_tasks::DEPLOY_TYPE,
#[cfg(feature = "website")]
website_tasks::FILES_SHOW_TYPE,
#[cfg(feature = "website")]
website_tasks::FILES_LIST_TYPE,
#[cfg(feature = "website")]
website_tasks::FILES_DELETE_TYPE,
#[cfg(feature = "website")]
website_tasks::GENERATIONS_LIST_TYPE,
#[cfg(feature = "website")]
website_tasks::ROLLBACK_TYPE,
discovery::DISCOVERY_V0_3_TYPE,
];
pub(crate) const PERSONHOOD_CHALLENGE_TYPE: &str =
<pc::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const PERSONHOOD_ASSERT_TYPE: &str = <pa::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const MEMBER_CREDENTIALS_TYPE: &str =
<member_credentials::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const MEMBER_UPDATE_TYPE: &str =
<member_update::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const MEMBER_ADMIN_REMOVE_TYPE: &str =
<member_admin_remove::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const MEMBER_PURGE_TYPE: &str =
<member_purge::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const JOIN_DECIDE_TYPE: &str =
<join_decide::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const COMMUNITY_PROFILE_UPDATE_TYPE: &str =
<community_profile_update::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const CONFIG_EXPORT_TYPE: &str =
<config_export::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const CONFIG_IMPORT_TYPE: &str =
<config_import::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ENDORSEMENT_TYPE_REGISTER_TYPE: &str =
<endorsement_type_register::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ENDORSEMENT_TYPE_DELETE_TYPE: &str =
<endorsement_type_delete::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const BACKUP_EXPORT_TYPE: &str =
<backup_export::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_GRANT_TYPE: &str = <acl_grant::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_CHANGE_ROLE_TYPE: &str =
<acl_change_role::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_SHOW_TYPE: &str = <acl_show::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_LIST_TYPE: &str = <acl_list::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_UPDATE_TYPE: &str = <acl_update::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_REVOKE_TYPE: &str = <acl_revoke::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_GRANT_V0_2_TYPE: &str =
<trust_tasks_rs::specs::acl::grant::v0_2::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_CHANGE_ROLE_V0_2_TYPE: &str =
<trust_tasks_rs::specs::acl::change_role::v0_2::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_SHOW_V0_2_TYPE: &str =
<trust_tasks_rs::specs::acl::show::v0_2::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_LIST_V0_2_TYPE: &str =
<trust_tasks_rs::specs::acl::list::v0_2::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_UPDATE_V0_2_TYPE: &str =
<trust_tasks_rs::specs::acl::update::v0_2::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_REVOKE_V0_2_TYPE: &str =
<trust_tasks_rs::specs::acl::revoke::v0_2::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const ACL_SWAP_KEY_TYPE: &str =
<trust_tasks_rs::specs::acl::swap_key::v0_1::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const STEP_UP_APPROVE_RESPONSE_TYPE: &str =
<step_up_approve_response::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const STEP_UP_APPROVE_RESPONSE_V0_5_TYPE: &str =
<step_up_approve_response_v0_5::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const STEP_UP_APPROVE_RESPONSE_V0_6_TYPE: &str =
<step_up_approve_response_v0_6::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub(crate) const SUBMIT_ERR_PRESENTATION_INVALID: &str =
jr::JOIN_REQUEST_SUBMIT_ERR_PRESENTATION_INVALID;
async fn resolve_holder(
state: &AppState,
ctx: &JoinAuthCtx,
doc: &TrustTask<Value>,
) -> Result<String, TrustTaskOutcome> {
let proven = match (&ctx.sender_did, &ctx.verified_signer) {
(Some(did), _) => did.clone(),
(None, Some(signer)) => signer.clone(),
(None, None) => match verify_trust_task_proof(state, doc).await {
Ok(did) => did,
Err(e) => return Err(app_error_to_reject(doc, &e)),
},
};
if let Some(issuer) = doc.issuer.as_deref() {
let issuer_base = issuer.split('#').next().unwrap_or(issuer);
if issuer_base != proven {
return Err(reject_with(
doc,
RejectReason::PermissionDenied {
reason: format!(
"document issuer ({issuer_base}) does not match the authenticated holder ({proven})"
),
},
));
}
}
Ok(proven)
}
async fn handle_submit(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let applicant_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: JoinRequestSubmitBody = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let outcome = match crate::join::submit_inner(
state,
applicant_did,
body.vp,
body.registry_consent,
body.extensions,
body.attributes
.into_iter()
.map(|a| crate::join::SubmittedAttribute {
r#type: a.claim_type,
value: a.value,
})
.collect(),
body.criterion,
None,
ctx.transport,
)
.await
{
Ok(o) => o,
Err(crate::join::SubmitRefusal::AlreadyOpen { request_id, status }) => {
let refusal = crate::join::SubmitRefusal::AlreadyOpen { request_id, status };
return reject_with_code_because(
&doc,
extended_code(jr::JOIN_REQUEST_SUBMIT_ERR_REQUEST_ALREADY_OPEN),
AppError::from(refusal).to_string(),
Some(serde_json::json!({
"requestId": request_id.to_string(),
"status": status.to_string(),
})),
reasons::CONFLICT,
);
}
Err(crate::join::SubmitRefusal::AttributesMissing(types)) => {
let details = serde_json::json!({ "types": types });
return reject_with_code(
&doc,
extended_code(jr::JOIN_REQUEST_SUBMIT_ERR_ATTRIBUTES_MISSING),
AppError::from(crate::join::SubmitRefusal::AttributesMissing(types)).to_string(),
Some(details),
);
}
Err(crate::join::SubmitRefusal::AttributesUnrequested(types)) => {
let details = serde_json::json!({ "types": types });
return reject_with_code(
&doc,
extended_code(jr::JOIN_REQUEST_SUBMIT_ERR_ATTRIBUTES_UNREQUESTED),
AppError::from(crate::join::SubmitRefusal::AttributesUnrequested(types))
.to_string(),
Some(details),
);
}
Err(crate::join::SubmitRefusal::PresentationInvalid(reason)) => {
return reject_with_code(
&doc,
extended_code(SUBMIT_ERR_PRESENTATION_INVALID),
reason,
None,
);
}
Err(crate::join::SubmitRefusal::NotAccepting) => {
return reject_with_code(
&doc,
extended_code(jr::JOIN_REQUEST_SUBMIT_ERR_NOT_ACCEPTING),
AppError::from(crate::join::SubmitRefusal::NotAccepting).to_string(),
None,
);
}
Err(crate::join::SubmitRefusal::CriterionUnknown(digest)) => {
let details = serde_json::json!({ "criterion": digest });
return reject_with_code(
&doc,
extended_code(jr::JOIN_REQUEST_SUBMIT_ERR_CRITERION_UNKNOWN),
AppError::from(crate::join::SubmitRefusal::CriterionUnknown(digest)).to_string(),
Some(details),
);
}
Err(crate::join::SubmitRefusal::Challenge(r)) => {
return challenge_reject(
&doc,
crate::vetting::challenge_refusal::ChallengeTask::Submit,
&r,
);
}
Err(crate::join::SubmitRefusal::Other(e)) => return app_error_to_reject(&doc, &e),
};
match outcome_to_verdict(&outcome) {
Ok(v) => verdict_response(&doc, v),
Err(e) => app_error_to_reject(&doc, &e),
}
}
fn challenge_reject<P>(
doc: &TrustTask<P>,
task: crate::vetting::challenge_refusal::ChallengeTask,
refusal: &crate::vetting::challenge_refusal::ChallengeRefusal,
) -> TrustTaskOutcome {
reject_with_code_because(
doc,
extended_code(refusal.code(task)),
refusal.to_string(),
refusal.details(),
refusal.marker(),
)
}
fn outcome_to_verdict(outcome: &JoinSubmitOutcome) -> Result<VerdictResponse, AppError> {
use crate::ceremony::verdict::Verdict as PolicyVerdict;
let request_id = outcome.request.id;
if let Some(admit) = &outcome.admit {
let role = outcome
.request
.policy_decision
.clone()
.and_then(|pd| serde_json::from_value::<PolicyVerdict>(pd).ok())
.and_then(|v| match v {
PolicyVerdict::Allow(a) => a.role,
_ => None,
});
let vmc = serde_json::to_value(&admit.vmc)
.map_err(|e| AppError::Internal(format!("serialise VMC: {e}")))?;
let role_vac = serde_json::to_value(&admit.role_vac)
.map_err(|e| AppError::Internal(format!("serialise role VAC: {e}")))?;
return Ok(VerdictResponse::allow(
request_id,
role,
Some(vmc),
Some(role_vac),
));
}
let decision = outcome
.request
.policy_decision
.clone()
.and_then(|pd| serde_json::from_value::<PolicyVerdict>(pd).ok());
let verdict = match decision {
Some(PolicyVerdict::RequestMore(rm)) => VerdictResponse {
request_id,
verdict: jr::Verdict {
effect: jr::VerdictEffect::RequestMore,
with: jr::VerdictWith {
needs: rm.needs,
presentation_definition: Some(rm.presentation_definition),
..Default::default()
},
},
},
Some(PolicyVerdict::Deny(d)) => VerdictResponse::deny(request_id, d.code, d.reason),
Some(PolicyVerdict::Refer(r)) => {
VerdictResponse::refer(request_id, r.queue, r.reason.unwrap_or_default())
}
_ => VerdictResponse::refer(
request_id,
"admin-review",
"queued for an admin decision (approve/reject)",
),
};
Ok(verdict)
}
async fn handle_revoke_statement(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let vetter_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: revoke_statement::Payload = match parse_checked_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let answer = crate::vetting::revocation::withdraw(state, &vetter_did, &body)
.await
.and_then(|notice| {
revoke_statement::Response::try_from(
revoke_statement::Response::builder().recorded_at(notice.recorded_at),
)
.map_err(|e| AppError::Internal(format!("revoke-statement response: {e}")).into())
});
match answer {
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_vetter_grant(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let admin_did = match capable_signer(
state,
ctx,
&doc,
crate::acl::Capability::VettingManage,
None,
)
.await
{
Ok(claims) => claims.did,
Err(reject) => return reject,
};
let body: vetting_wire::vetters::grant::v0_1::Payload = match parse_checked_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::vetting::vetters::grant(state, &admin_did, &body).await {
Ok(grant) => success_response(&doc, grant.response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
#[cfg(feature = "vetting-pcs")]
async fn handle_pcs_root(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let member_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
match crate::vetting::pcs_tasks::handle_pcs_root(state, &member_did, &doc).await {
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
#[cfg(feature = "vetting-pcs")]
async fn handle_pcs_tokens(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let member_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
match crate::vetting::pcs_tasks::handle_pcs_tokens(state, &member_did, &doc).await {
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
#[cfg(feature = "vetting-pcs")]
async fn handle_event_mode(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let member_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
match crate::vetting::pcs_tasks::handle_event_mode(state, &member_did, &doc).await {
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
#[cfg(feature = "vetting-pcs")]
async fn handle_pcs_challenge(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let applicant_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
match crate::vetting::pcs_tasks::handle_pcs_challenge(state, &applicant_did, &doc).await {
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
#[cfg(feature = "vetting-pcs")]
async fn handle_hidden_publish(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor = match capable_signer(
state,
ctx,
&doc,
crate::acl::Capability::VettingManage,
None,
)
.await
{
Ok(claims) => claims,
Err(reject) => return reject,
};
let payload: trust_tasks_rs::specs::vtc::vetting::hidden::publish::v0_1::Payload =
match parse_spec_payload(&doc) {
Ok(p) => p,
Err(reject) => return reject,
};
let live_periods = payload
.live_periods
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>();
let live_token_labels = payload
.live_token_labels
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>();
let events = if payload.events.is_empty() {
None
} else {
match serde_json::to_value(&payload.events) {
Ok(v) => Some(v),
Err(e) => {
return app_error_to_reject(
&doc,
&AppError::Internal(format!("encode events: {e}")),
);
}
}
};
match crate::routes::vetting_hidden::publish_hidden_vetting_core(
state,
&actor,
&doc,
payload.criterion_id.to_string(),
if live_periods.is_empty() {
None
} else {
Some(live_periods)
},
if live_token_labels.is_empty() {
None
} else {
Some(live_token_labels)
},
payload.drip_per_tick.map(|n| n.get() as usize),
payload.tick_length.as_ref().map(|t| t.to_string()),
events,
)
.await
{
Ok(response) => success_response(&doc, response),
Err(crate::routes::vetting_hidden::Halted::Refused(e)) => task_error_to_reject(&doc, &e),
Err(crate::routes::vetting_hidden::Halted::Gate(outcome)) => outcome,
}
}
#[cfg(feature = "vetting-pcs")]
async fn handle_hidden_withdraw(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor = match capable_signer(
state,
ctx,
&doc,
crate::acl::Capability::VettingManage,
None,
)
.await
{
Ok(claims) => claims,
Err(reject) => return reject,
};
let payload: trust_tasks_rs::specs::vtc::vetting::hidden::withdraw::v0_1::Payload =
match parse_spec_payload(&doc) {
Ok(p) => p,
Err(reject) => return reject,
};
match crate::routes::vetting_hidden::withdraw_hidden_vetting_core(
state,
&actor,
&doc,
payload.criterion_id.to_string(),
)
.await
{
Ok(response) => success_response(&doc, response),
Err(crate::routes::vetting_hidden::Halted::Refused(e)) => task_error_to_reject(&doc, &e),
Err(crate::routes::vetting_hidden::Halted::Gate(outcome)) => outcome,
}
}
#[cfg(feature = "vetting-pcs")]
async fn handle_hidden_show(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(reject) = capable_signer(
state,
ctx,
&doc,
crate::acl::Capability::VettingManage,
None,
)
.await
{
return reject;
}
let payload: trust_tasks_rs::specs::vtc::vetting::hidden::show::v0_1::Payload =
match parse_spec_payload(&doc) {
Ok(p) => p,
Err(reject) => return reject,
};
match crate::routes::vetting_hidden::show_hidden_vetting_core(
state,
payload.criterion_id.to_string(),
)
.await
{
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
fn parse_checked_payload<P>(doc: &TrustTask<Value>) -> Result<P, TrustTaskOutcome>
where
P: trust_tasks_rs::Payload + serde::de::DeserializeOwned + vetting_wire::CheckShape,
{
vetting_wire::read_checked::<P>(&doc.payload).map_err(|e| {
reject_with(
doc,
RejectReason::MalformedRequest {
reason: format!("payload: {e}"),
},
)
})
}
async fn handle_vetter_profile(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let vetter_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: vetting_wire::vetters::profile::v0_1::Payload = match parse_checked_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::vetting::profiles::publish(state, &vetter_did, &body, doc.issued_at).await {
Ok(response) => success_response(&doc, response),
Err(AppError::Forbidden(reason)) => reject_with_code(
&doc,
extended_code(vetting_wire::VETTING_VETTER_PROFILE_ERR_NOT_ELIGIBLE),
reason,
None,
),
Err(e) => app_error_to_reject(&doc, &e),
}
}
async fn handle_vetter_list(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(reject) = resolve_holder(state, ctx, &doc).await {
return reject;
}
let body: vetting_wire::vetters::list::v0_1::Payload = match parse_checked_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::vetting::profiles::list(state, &body).await {
Ok(response) => success_response(&doc, response),
Err(e) => app_error_to_reject(&doc, &e),
}
}
async fn handle_vetter_show(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(reject) = resolve_holder(state, ctx, &doc).await {
return reject;
}
let body: vetting_wire::vetters::show::v0_1::Payload = match parse_checked_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::vetting::profiles::show(state, &body).await {
Ok(response) => success_response(&doc, response),
Err(e) => app_error_to_reject(&doc, &e),
}
}
async fn handle_vetter_resend(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use trust_tasks_rs::{StandardCode, TrustTaskCode};
let vetter_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
if let Err(reject) = parse_checked_payload::<vetting_wire::vetters::resend::v0_1::Payload>(&doc)
{
return reject;
}
match crate::vetting::vetters::resend(state, &vetter_did, &vetter_did).await {
Ok(response) => success_response(&doc, response),
Err(AppError::NotFound(reason)) => reject_with_code_because(
&doc,
extended_code(vetting_wire::VETTING_VETTER_RESEND_ERR_NOT_GRANTED),
reason,
None,
reasons::NOT_FOUND,
),
Err(AppError::ServiceError { status, message })
if status == axum::http::StatusCode::SERVICE_UNAVAILABLE =>
{
reject_with_code(
&doc,
TrustTaskCode::Standard(StandardCode::Unavailable),
message,
None,
)
}
Err(e) => app_error_to_reject(&doc, &e),
}
}
async fn handle_vetter_resend_v0_2(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use trust_tasks_rs::{StandardCode, TrustTaskCode};
let body: vetting_wire::vetters::resend::v0_2::Payload = match parse_checked_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let (actor_did, subject_did) = match body.member_did.as_ref() {
Some(member_did) => {
let admin = match capable_signer(
state,
ctx,
&doc,
crate::acl::Capability::VettingManage,
None,
)
.await
{
Ok(a) => a,
Err(reject) => return reject,
};
(admin.did, member_did.to_string())
}
None => {
let vetter_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
(vetter_did.clone(), vetter_did)
}
};
match crate::vetting::vetters::resend(state, &actor_did, &subject_did).await {
Err(AppError::NotFound(reason)) => reject_with_code_because(
&doc,
extended_code(vetting_wire::VETTING_VETTER_RESEND_ERR_NOT_GRANTED),
reason,
None,
reasons::NOT_FOUND,
),
Err(AppError::ServiceError { status, message })
if status == axum::http::StatusCode::SERVICE_UNAVAILABLE =>
{
reject_with_code(
&doc,
TrustTaskCode::Standard(StandardCode::Unavailable),
message,
None,
)
}
Err(e) => app_error_to_reject(&doc, &e),
Ok(response) => success_response(&doc, response),
}
}
fn caller_is_identified(ctx: &JoinAuthCtx) -> bool {
ctx.verified_signer.is_some() || ctx.sender_did.is_some()
}
async fn handle_manifest(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use crate::routes::join_requests::manifest::manifest_v0_3;
if !caller_is_identified(ctx) {
let public = match crate::community::join_discovery::load_join_discovery(
&state.community_ks,
)
.await
{
Ok(setting) => setting.public,
Err(e) => {
tracing::warn!(error = %e, "join discovery unreadable; answering the manifest");
true
}
};
if !public {
return reject_with(
&doc,
RejectReason::PermissionDenied {
reason: "this community does not publish its join requirements to callers \
it cannot identify — ask over DIDComm, or with a signed Trust Task \
document"
.to_string(),
},
);
}
}
manifest_v0_3(state)
.await
.map(|r| success_response(&doc, r))
.unwrap_or_else(|e| app_error_to_reject(&doc, &e))
}
async fn handle_status(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let applicant_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: JoinRequestStatusBody = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let result = match body.request_id {
Some(request_id) => {
crate::routes::join_requests::status::status_inner(
state,
request_id,
applicant_did,
body.resend_credentials,
)
.await
}
None => {
crate::routes::join_requests::status::status_by_applicant(
state,
applicant_did,
body.resend_credentials,
)
.await
}
};
match result {
Ok(resp) => success_response(&doc, resp),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_supplement(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use crate::join::SupplementRefusal;
let applicant_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: jr::supplement::v0_1::Payload = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let request_id = match body.request_id.as_ref().map(|r| uuid::Uuid::parse_str(r)) {
None => None,
Some(Ok(id)) => Some(id),
Some(Err(e)) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("requestId is not a UUID: {e}"),
},
);
}
};
let outcome = match crate::join::supplement_inner(
state,
&applicant_did,
request_id,
Value::Object(body.vp.clone()),
Value::Object(body.extensions.clone()),
ctx.transport,
)
.await
{
Ok(o) => o,
Err(e @ SupplementRefusal::NotFound(_)) => {
return reject_with_code_because(
&doc,
extended_code(jr::JOIN_REQUEST_SUPPLEMENT_ERR_NOT_FOUND),
AppError::from(e).to_string(),
None,
reasons::NOT_FOUND,
);
}
Err(SupplementRefusal::NotAwaitingEvidence { request_id, status }) => {
let refusal = SupplementRefusal::NotAwaitingEvidence { request_id, status };
return reject_with_code_because(
&doc,
extended_code(jr::JOIN_REQUEST_SUPPLEMENT_ERR_NOT_AWAITING_EVIDENCE),
AppError::from(refusal).to_string(),
Some(serde_json::json!({
"requestId": request_id.to_string(),
"status": status.to_string(),
})),
reasons::CONFLICT,
);
}
Err(SupplementRefusal::AlreadyDecided { request_id, status }) => {
let refusal = SupplementRefusal::AlreadyDecided { request_id, status };
return reject_with_code_because(
&doc,
extended_code(jr::JOIN_REQUEST_SUPPLEMENT_ERR_ALREADY_DECIDED),
AppError::from(refusal).to_string(),
Some(serde_json::json!({
"requestId": request_id.to_string(),
"status": status.to_string(),
})),
reasons::GONE,
);
}
Err(SupplementRefusal::Challenge(r)) => {
return challenge_reject(
&doc,
crate::vetting::challenge_refusal::ChallengeTask::Supplement,
&r,
);
}
Err(SupplementRefusal::Other(e)) => return app_error_to_reject(&doc, &e),
};
match outcome_to_verdict(&outcome) {
Ok(v) => verdict_response(&doc, v),
Err(e) => app_error_to_reject(&doc, &e),
}
}
async fn handle_withdraw(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let applicant_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: jr::withdraw::v0_1::Payload = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let request_id = match body.request_id.as_ref().map(|r| uuid::Uuid::parse_str(r)) {
None => None,
Some(Ok(id)) => Some(id),
Some(Err(e)) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("requestId is not a UUID: {e}"),
},
);
}
};
match crate::join::orchestrate::withdraw_inner(
state,
&applicant_did,
request_id,
body.reason.as_ref().map(|r| r.to_string()),
)
.await
{
Ok(request) => {
let response = match jr::withdraw::v0_1::Response::builder()
.request_id(request.id.to_string())
.status(jr::withdraw::v0_1::ResponseStatus::Withdrawn)
.try_into()
{
Ok(r) => r,
Err(e) => {
return reject_with(
&doc,
RejectReason::InternalError {
reason: format!("withdraw response failed its own schema: {e}"),
},
);
}
};
success_response::<_, jr::withdraw::v0_1::Response>(&doc, response)
}
Err(e @ AppError::NotFound(_)) => reject_with_code_because(
&doc,
extended_code(jr::JOIN_REQUEST_WITHDRAW_ERR_NOT_FOUND),
e.to_string(),
None,
reasons::NOT_FOUND,
),
Err(e @ AppError::Gone(_)) => reject_with_code_because(
&doc,
extended_code(jr::JOIN_REQUEST_WITHDRAW_ERR_ALREADY_DECIDED),
e.to_string(),
None,
reasons::GONE,
),
Err(e) => app_error_to_reject(&doc, &e),
}
}
async fn handle_self_remove(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let member_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: jr::SelfRemoveBody = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let disposition = match body
.disposition
.as_deref()
.map(crate::messaging::parse_disposition)
.transpose()
{
Ok(d) => d,
Err(reason) => return reject_with(&doc, RejectReason::MalformedRequest { reason }),
};
match crate::ceremony::orchestrate::remove_inner(
state,
&member_did,
&member_did,
disposition,
String::new(),
None,
)
.await
{
Ok(outcome) => success_response(
&doc,
jr::SelfRemoveReceiptBody {
did: outcome.did,
disposition: outcome.disposition,
removed: outcome.removed,
},
),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn admin_signer(
state: &AppState,
ctx: &JoinAuthCtx,
doc: &TrustTask<Value>,
) -> Result<vti_common::auth::extractor::AuthClaims, TrustTaskOutcome> {
let Some(signer) = ctx.verified_signer.clone() else {
return Err(reject_with(doc, RejectReason::ProofRequired));
};
let has_own_row = crate::acl::get_acl_entry(&state.acl_ks, &signer)
.await
.map_err(|e| app_error_to_reject(doc, &e))?
.is_some();
if !has_own_row
&& let Some(delegation) =
crate::acl::console_key::resolve_delegated_admin(&state.console_keys_ks, &signer)
.await
.map_err(|e| app_error_to_reject(doc, &e))?
{
let claims = resolve_admin_claims(state, doc, &delegation.admin_did).await?;
crate::acl::console_key::touch_last_used(&state.console_keys_ks, &delegation).await;
tracing::info!(
console_did = %signer,
admin_did = %delegation.admin_did,
task = %doc.type_uri,
"authorizing a signed document under a console-key delegation"
);
return Ok(claims);
}
resolve_admin_claims(state, doc, &signer).await
}
pub(crate) async fn capable_signer(
state: &AppState,
ctx: &JoinAuthCtx,
doc: &TrustTask<Value>,
cap: crate::acl::Capability,
resource: Option<&crate::acl::ResourceQualifier>,
) -> Result<vti_common::auth::extractor::AuthClaims, TrustTaskOutcome> {
let claims = admin_signer(state, ctx, doc).await?;
crate::acl::require_capability(&state.acl_ks, &claims.did, cap, resource)
.await
.map_err(|e| app_error_to_reject(doc, &e))?;
Ok(claims)
}
const SUSPENDED_MAY_SEND: &[&str] = &[
action_tasks::LIST_TYPE,
action_tasks::LIST_V0_2_TYPE,
action_tasks::SHOW_TYPE,
action_tasks::SHOW_V0_2_TYPE,
action_tasks::CANCEL_TYPE,
action_tasks::CANCEL_V0_2_TYPE,
];
async fn resolve_admin_claims(
state: &AppState,
doc: &TrustTask<Value>,
did: &str,
) -> Result<vti_common::auth::extractor::AuthClaims, TrustTaskOutcome> {
let (role, allowed_contexts) = crate::acl::resolve_auth_role(&state.acl_ks, did)
.await
.map_err(|e| app_error_to_reject(doc, &e))?;
if !SUSPENDED_MAY_SEND.contains(&doc.type_uri.to_string().as_str())
&& let Some(s) = crate::acl::storage::get_suspension(&state.acl_ks, did)
.await
.map_err(|e| app_error_to_reject(doc, &e))?
{
return Err(app_error_to_reject(doc, &s.refusal(did)));
}
Ok(vti_common::auth::extractor::AuthClaims {
did: did.to_string(),
role,
allowed_contexts,
..Default::default()
})
}
fn parse_spec_payload<P>(doc: &TrustTask<Value>) -> Result<P, TrustTaskOutcome>
where
P: trust_tasks_rs::validate::ValidatedPayload + serde::de::DeserializeOwned,
{
if let Err(e) = P::validate_value(&doc.payload) {
return Err(reject_with(
doc,
RejectReason::MalformedRequest {
reason: format!("payload: {e}"),
},
));
}
parse_payload::<P>(doc)
}
async fn handle_member_credentials(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor = match capable_signer(
state,
ctx,
&doc,
crate::acl::Capability::MembersManage,
None,
)
.await
{
Ok(a) => a,
Err(reject) => return reject,
};
let body: member_credentials::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::routes::members::credentials::read_member_credentials(
state,
&actor.did,
body.did.as_str(),
)
.await
{
Ok(response) => success_response(&doc, response),
Err(crate::routes::members::credentials::CredentialsError::NotFound(message)) => {
task_error_to_reject(
&doc,
&crate::error::TaskError::declared(
crate::routes::members::credentials::MEMBER_CREDENTIALS_ERR_NOT_FOUND,
AppError::NotFound(message),
),
)
}
Err(crate::routes::members::credentials::CredentialsError::Other(e)) => {
app_error_to_reject(&doc, &e)
}
}
}
async fn handle_member_update(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor = match capable_signer(
state,
ctx,
&doc,
crate::acl::Capability::MembersManage,
None,
)
.await
{
Ok(a) => a,
Err(reject) => return reject,
};
let checked: member_update::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let req: crate::routes::members::update::UpdateMemberRequest = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::routes::members::update::update_member_inner(
state,
&actor,
checked.did.as_str(),
req,
)
.await
{
Ok(envelope) => success_response(&doc, envelope),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_member_admin_remove(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor = match capable_signer(
state,
ctx,
&doc,
crate::acl::Capability::MembersManage,
None,
)
.await
{
Ok(a) => a,
Err(reject) => return reject,
};
let checked: member_admin_remove::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let body: crate::routes::members::remove::RemoveBody = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::routes::members::remove::admin_remove_inner(
state,
&actor,
checked.did.as_str(),
body,
crate::acl::admin_consent::Operation {
type_uri: &doc.type_uri.to_string(),
payload: &doc.payload,
},
)
.await
{
Ok(outcome) => success_response(
&doc,
crate::routes::members::remove::RemoveResponse::from(outcome),
),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_member_purge(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor = match capable_signer(
state,
ctx,
&doc,
crate::acl::Capability::MembersManage,
None,
)
.await
{
Ok(a) => a,
Err(reject) => return reject,
};
let checked: member_purge::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
if let Err(e) = vti_common::identifier::validate_did("did", checked.did.as_str()) {
return app_error_to_reject(&doc, &e);
}
match crate::ceremony::purge_member(state, &actor.did, checked.did.as_str()).await {
Ok(outcome) => success_response(
&doc,
crate::routes::members::remove::RemoveResponse::from(outcome),
),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_join_decide(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor =
match capable_signer(state, ctx, &doc, crate::acl::Capability::JoinDecide, None).await {
Ok(a) => a,
Err(reject) => return reject,
};
let checked: join_decide::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let id = match uuid::Uuid::parse_str(checked.id.as_str()) {
Ok(id) => id,
Err(e) => {
return app_error_to_reject(
&doc,
&AppError::Validation(format!("`id` is not a join-request id: {e}")),
);
}
};
let decision = match &checked.decision {
join_decide::PayloadDecision::Approved => {
crate::routes::join_requests::decide::Decision::Approved
}
join_decide::PayloadDecision::Rejected => {
crate::routes::join_requests::decide::Decision::Rejected
}
other => {
return app_error_to_reject(
&doc,
&AppError::Validation(format!("unsupported `decision`: {other}")),
);
}
};
let body = crate::routes::join_requests::decide::DecideBody {
decision,
reason: checked.reason.as_ref().map(|r| r.as_str().to_string()),
};
match crate::routes::join_requests::decide::decide_inner(
state,
&actor.did,
ctx.transport.as_str(),
id,
body,
)
.await
{
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_community_profile_update(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor =
match capable_signer(state, ctx, &doc, crate::acl::Capability::SurfaceAdmin, None).await {
Ok(a) => a,
Err(reject) => return reject,
};
let _checked: community_profile_update::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let update: crate::community::CommunityProfileUpdate = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::routes::community::profile::update_profile_inner(state, &actor.did, update).await {
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_config_export(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let cap = crate::admin_events::read_capability(crate::admin_events::Topic::Config)
.unwrap_or(crate::acl::Capability::ConfigAdmin);
if let Err(reject) = capable_signer(state, ctx, &doc, cap, None).await {
return reject;
}
let _checked: config_export::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::routes::admin::config::export_inner(state).await {
Ok(response) => success_response(&doc, response),
Err(e) => app_error_to_reject(&doc, &e),
}
}
async fn handle_config_import(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor =
match capable_signer(state, ctx, &doc, crate::acl::Capability::ConfigAdmin, None).await {
Ok(a) => a,
Err(reject) => return reject,
};
let _checked: config_import::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let request: crate::routes::admin::config::ImportRequest = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
if request.confirm
&& request.document.schema_version == crate::routes::admin::config::EXPORT_SCHEMA_VERSION
{
if let Err(refusal) = acl_tasks::settle_witness_list_gate(
state,
&actor,
&doc,
&request.document.config_overrides,
", by configuration import",
)
.await
{
return refusal;
}
match crate::routes::admin::config::lowered_threshold(
state,
&request.document.config_overrides,
)
.await
{
Ok(None) => {}
Ok(Some((from, to))) => {
if let Err(refusal) = acl_tasks::settle_consent_gate(
state,
&actor,
&doc,
crate::acl::admin_consent::Act::LowerThreshold,
crate::config_store::UNRESTRICTED_ADMIN_CONSENT_THRESHOLD,
&format!(
"Import a configuration that lowers the unrestricted-admin consent \
threshold from {from} to {to}"
),
&format!(
"Lower the number of administrators who must consent to a new \
unrestricted administrator from {from} to {to}, by configuration import"
),
)
.await
{
return refusal;
}
}
Err(e) => return app_error_to_reject(&doc, &e),
}
}
match crate::routes::admin::config::import_inner(state, &actor.did, request).await {
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_endorsement_type_register(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor =
match capable_signer(state, ctx, &doc, crate::acl::Capability::SurfaceAdmin, None).await {
Ok(a) => a,
Err(reject) => return reject,
};
let _checked: endorsement_type_register::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let body: crate::routes::endorsement_types::RegisterBody = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::routes::endorsement_types::register_inner(state, &actor.did, body).await {
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_endorsement_type_delete(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor =
match capable_signer(state, ctx, &doc, crate::acl::Capability::SurfaceAdmin, None).await {
Ok(a) => a,
Err(reject) => return reject,
};
let checked: endorsement_type_delete::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::routes::endorsement_types::delete_inner(
state,
&actor.did,
checked.type_uri.to_string(),
)
.await
{
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_backup_export(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let actor =
match capable_signer(state, ctx, &doc, crate::acl::Capability::BackupExport, None).await {
Ok(a) => a,
Err(reject) => return reject,
};
if let Err(reject) = refuse_hop_by_hop_backup(ctx, &doc, "export") {
return reject;
}
let checked: backup_export::Payload = match parse_spec_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::routes::backup::export_inner(
state,
&actor.did,
checked.password.as_str(),
checked.include_audit,
)
.await
{
Ok(response) => success_response(&doc, response),
Err(e) => task_error_to_reject(&doc, &e),
}
}
pub(super) fn refuse_hop_by_hop_backup(
ctx: &JoinAuthCtx,
doc: &TrustTask<Value>,
what: &str,
) -> Result<(), TrustTaskOutcome> {
match ctx.transport {
JoinTransport::DIDComm | JoinTransport::Tsp => Ok(()),
JoinTransport::Rest => Err(app_error_to_reject(
doc,
&AppError::Forbidden(format!(
"a backup {what} is refused over REST: the backup password and the bundle \
would exist in plaintext wherever TLS terminates. Send it over DIDComm or TSP"
)),
)),
}
}
async fn handle_step_up_approve_response(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use crate::acl::bound_step_up::{self, ApproveError, Approved};
use step_up_approve_response::error_codes as codes;
let payload: step_up_approve_response::Payload = match parse_spec_payload(&doc) {
Ok(p) => p,
Err(reject) => return reject,
};
let refuse = |code: trust_tasks_rs::DeclaredErrorCode, message: &str, hint: Option<&str>| {
reject_with_code(
&doc,
extended_code(code.code),
message,
hint.map(|h| serde_json::json!({ "reason": h })),
)
};
let signed_by_subject = match ctx.verified_signer.as_deref() {
Some(signer) if signer == payload.subject.as_str() => true,
Some(_) => {
return refuse(
codes::SUBJECT_MISMATCH,
"the approve-response is not signed by the subject of the step-up",
None,
);
}
None => false,
};
if !signed_by_subject && !unsigned_answer_admissible(state, &payload).await {
return reject_with(&doc, RejectReason::ProofRequired);
}
match bound_step_up::approve(state, &payload, signed_by_subject).await {
Ok(Approved::Recorded { bound_to }) => success_response(
&doc,
serde_json::json!({ "status": "recorded", "boundTo": bound_to }),
),
Ok(Approved::Declined { reason }) => success_response(
&doc,
serde_json::json!({ "status": "rejected", "reason": reason }),
),
Err(ApproveError::ChallengeUnknown) => refuse(
codes::CHALLENGE_UNKNOWN,
"no pending step-up matches this challenge",
None,
),
Err(ApproveError::ChallengeExpired) => refuse(
codes::CHALLENGE_EXPIRED,
"the step-up this challenge belonged to has expired; send the operation again",
None,
),
Err(ApproveError::SubjectMismatch) => refuse(
codes::SUBJECT_MISMATCH,
"the subject or session does not match the pending step-up",
None,
),
Err(ApproveError::NoGate) => refuse(
codes::NO_GATE,
"this step-up accepts only a passkey assertion (evidence.kind = webauthn)",
None,
),
Err(ApproveError::AssertionInvalid(hint)) => refuse(
codes::ASSERTION_INVALID,
"the passkey assertion did not verify",
Some(hint),
),
Err(ApproveError::ProofRequired) => reject_with(&doc, RejectReason::ProofRequired),
Err(ApproveError::StatementInvalid | ApproveError::ApproverNotBound) => refuse(
codes::NO_GATE,
"this version carries no step-up approver statement",
None,
),
Err(ApproveError::Internal(e)) => app_error_to_reject(&doc, &e),
}
}
async fn handle_step_up_approve_response_v0_6(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use crate::acl::bound_step_up::{self, ApproveError, Approved};
use step_up_approve_response_v0_6::error_codes as codes;
let payload: step_up_approve_response_v0_6::Payload = match parse_spec_payload(&doc) {
Ok(p) => p,
Err(reject) => return reject,
};
let refuse = |code: trust_tasks_rs::DeclaredErrorCode, message: &str, hint: Option<&str>| {
reject_with_code(
&doc,
extended_code(code.code),
message,
hint.map(|h| serde_json::json!({ "reason": h })),
)
};
match ctx.verified_signer.as_deref() {
Some(signer) if signer == payload.subject.as_str() => {}
Some(_) => {
return refuse(
codes::SUBJECT_MISMATCH,
"an approve-response must be signed by the subject's own DID",
None,
);
}
None => return reject_with(&doc, RejectReason::ProofRequired),
}
let approver_signed = matches!(
payload.evidence,
Some(step_up_approve_response_v0_6::Evidence::ApproverSigned { .. })
);
let outcome = if approver_signed {
if payload.decision != step_up_approve_response_v0_6::PayloadDecision::Approved {
return refuse(
codes::NO_GATE,
"a refusal is signed by the subject and carries no approver statement \
(evidence.kind = didSigned)",
None,
);
}
let Some(statement) = doc.payload.pointer("/evidence/statement") else {
return refuse(codes::STATEMENT_INVALID, "no statement", None);
};
bound_step_up::approve_with_statement(
state,
payload.subject.as_str(),
payload.challenge.as_str(),
payload.session_id.is_some(),
statement,
)
.await
} else {
match serde_json::from_value::<step_up_approve_response::Payload>(doc.payload.clone()) {
Ok(v0_4) => bound_step_up::approve(state, &v0_4, true).await,
Err(e) => {
return app_error_to_reject(
&doc,
&AppError::Internal(format!(
"approve-response 0.6 payload does not convert to 0.4's shape: {e}"
)),
);
}
}
};
match outcome {
Ok(Approved::Recorded { bound_to }) => success_response(
&doc,
serde_json::json!({ "status": "recorded", "boundTo": bound_to }),
),
Ok(Approved::Declined { reason }) => success_response(
&doc,
serde_json::json!({ "status": "rejected", "reason": reason }),
),
Err(ApproveError::ChallengeUnknown) => refuse(
codes::CHALLENGE_UNKNOWN,
"no pending step-up matches this challenge",
None,
),
Err(ApproveError::ChallengeExpired) => refuse(
codes::CHALLENGE_EXPIRED,
"the step-up this challenge belonged to has expired; send the operation again",
None,
),
Err(ApproveError::SubjectMismatch) => refuse(
codes::SUBJECT_MISMATCH,
"the subject or session does not match the pending step-up",
None,
),
Err(ApproveError::NoGate) => refuse(
codes::NO_GATE,
"this step-up did not accept that kind of evidence; answer with one the request's \
`accepts` lists",
None,
),
Err(ApproveError::AssertionInvalid(hint)) => refuse(
codes::ASSERTION_INVALID,
"the passkey assertion did not verify",
Some(hint),
),
Err(ApproveError::StatementInvalid) => refuse(
codes::STATEMENT_INVALID,
"the step-up approver's statement is not valid for this step-up",
None,
),
Err(ApproveError::ApproverNotBound) => refuse(
codes::APPROVER_NOT_BOUND,
"the statement is not signed by a step-up approver bound to the subject",
None,
),
Err(ApproveError::ProofRequired) => reject_with(&doc, RejectReason::ProofRequired),
Err(ApproveError::Internal(e)) => app_error_to_reject(&doc, &e),
}
}
async fn unsigned_answer_admissible(
state: &AppState,
payload: &step_up_approve_response::Payload,
) -> bool {
use base64::Engine as _;
if payload.decision != step_up_approve_response::PayloadDecision::Approved {
return false;
}
let Some(step_up_approve_response::Evidence::Webauthn(assertion)) = payload.evidence.as_ref()
else {
return false;
};
let Ok(raw) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(&assertion.raw_id) else {
return false;
};
matches!(
vti_common::auth::passkey::store::get_passkey_user_by_cred(
&state.passkey_ks,
&hex::encode(raw)
)
.await,
Ok(Some(_))
)
}
async fn handle_step_up_approve_response_v0_5(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
use crate::acl::bound_step_up::{self, ApproveError, Approved};
use step_up_approve_response_v0_5::error_codes as codes;
let payload: step_up_approve_response_v0_5::Payload = match parse_spec_payload(&doc) {
Ok(p) => p,
Err(reject) => return reject,
};
let refuse = |code: trust_tasks_rs::DeclaredErrorCode, message: &str, hint: Option<&str>| {
reject_with_code(
&doc,
extended_code(code.code),
message,
hint.map(|h| serde_json::json!({ "reason": h })),
)
};
match ctx.verified_signer.as_deref() {
Some(signer) if signer == payload.subject.as_str() => {}
Some(_) => {
return refuse(
codes::SUBJECT_MISMATCH,
"the approve-response is not signed by the subject of the step-up",
None,
);
}
None => return reject_with(&doc, RejectReason::ProofRequired),
}
let v0_4_payload: step_up_approve_response::Payload =
match serde_json::to_value(&payload).and_then(serde_json::from_value) {
Ok(p) => p,
Err(e) => {
return app_error_to_reject(
&doc,
&AppError::Internal(format!(
"approve-response 0.5 payload does not convert to 0.4's shape: {e}"
)),
);
}
};
match bound_step_up::approve(state, &v0_4_payload, true).await {
Ok(Approved::Recorded { bound_to }) => success_response(
&doc,
serde_json::json!({ "status": "recorded", "boundTo": bound_to }),
),
Ok(Approved::Declined { reason }) => success_response(
&doc,
serde_json::json!({ "status": "rejected", "reason": reason }),
),
Err(ApproveError::ChallengeUnknown) => refuse(
codes::CHALLENGE_UNKNOWN,
"no pending step-up matches this challenge",
None,
),
Err(ApproveError::ChallengeExpired) => refuse(
codes::CHALLENGE_EXPIRED,
"the step-up this challenge belonged to has expired; send the operation again",
None,
),
Err(ApproveError::SubjectMismatch) => refuse(
codes::SUBJECT_MISMATCH,
"the subject or session does not match the pending step-up",
None,
),
Err(ApproveError::NoGate) => refuse(
codes::NO_GATE,
"this step-up accepts only a passkey assertion (evidence.kind = webauthn)",
None,
),
Err(ApproveError::AssertionInvalid(hint)) => refuse(
codes::ASSERTION_INVALID,
"the passkey assertion did not verify",
Some(hint),
),
Err(ApproveError::ProofRequired) => reject_with(&doc, RejectReason::ProofRequired),
Err(ApproveError::StatementInvalid | ApproveError::ApproverNotBound) => refuse(
codes::NO_GATE,
"this version carries no step-up approver statement; use approve-response 0.6",
None,
),
Err(ApproveError::Internal(e)) => app_error_to_reject(&doc, &e),
}
}
async fn handle_personhood_challenge(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let caller = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: pc::Payload = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
match crate::acl::get_acl_entry(&state.acl_ks, &caller).await {
Ok(Some(_)) => {}
Ok(None) => {
return reject_with(
&doc,
RejectReason::PermissionDenied {
reason: format!("{caller} is not a member of this community"),
},
);
}
Err(e) => return app_error_to_reject(&doc, &e),
}
match crate::routes::members::personhood::challenge_inner(state, &body.did).await {
Ok(res) => success_response(&doc, res),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_personhood_assert(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let caller = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: pa::Payload = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
if *body.did != caller {
return reject_with(
&doc,
RejectReason::PermissionDenied {
reason: format!(
"personhood is asserted by its subject; {caller} cannot assert for {}",
*body.did
),
},
);
}
let presentation = match serde_json::to_value(&body.presentation) {
Ok(v) => v,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("presentation is not representable as JSON: {e}"),
},
);
}
};
let evidence = match crate::credentials::witness::outcome_evidence_from_payload(&doc.payload) {
Ok(e) => e,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: e.to_string(),
},
);
}
};
match crate::routes::members::personhood::assert_inner(
state,
&body.did,
&presentation,
&evidence,
)
.await
{
Ok(res) => success_response(&doc, res),
Err(e) => task_error_to_reject(&doc, &e),
}
}
async fn handle_member_vmc(
state: &AppState,
ctx: &JoinAuthCtx,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
let member_did = match resolve_holder(state, ctx, &doc).await {
Ok(did) => did,
Err(reject) => return reject,
};
let body: MemberVmcBody = match parse_payload(&doc) {
Ok(b) => b,
Err(reject) => return reject,
};
let request_id = match body
.request_id
.as_deref()
.map(uuid::Uuid::parse_str)
.transpose()
{
Ok(r) => r,
Err(e) => {
return reject_with(
&doc,
RejectReason::MalformedRequest {
reason: format!("requestId is not a UUID: {e}"),
},
);
}
};
match crate::members::inbound_vmc::receive_member_vmc_inner(
state, member_did, body.vc, request_id,
)
.await
{
Ok(outcome) => success_response(
&doc,
MemberVmcReceiptBody {
member_did: outcome.member_did,
vmc_id: outcome.vmc_id,
status: "stored".to_string(),
request_id: outcome.request_id.map(|u| u.to_string()),
},
),
Err(e) => task_error_to_reject(&doc, &e),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn error_doc(outcome: TrustTaskOutcome) -> Value {
serde_json::from_slice(&outcome.body).expect("rejection is a framework error document")
}
fn request_doc() -> TrustTask<Value> {
let uri: trust_tasks_rs::TypeUri = jr::JOIN_REQUEST_MANIFEST_TYPE
.parse()
.expect("manifest uri");
TrustTask::new("urn:uuid:test", uri, serde_json::json!({}))
}
#[test]
fn task_family_strips_only_the_version_segment() {
assert_eq!(
task_family("scheme://host/spec/vtc/a/b/0.2"),
Some("scheme://host/spec/vtc/a/b")
);
assert_eq!(task_family("no-slashes-at-all"), None);
}
#[test]
fn an_unknown_family_is_still_unsupported_type() {
let outcome =
unsupported_type_or_version(&request_doc(), "scheme://host/spec/vtc/nope/1.0");
let doc = error_doc(outcome);
assert_eq!(doc["payload"]["code"], "unsupportedType");
assert!(doc["payload"]["details"].get("servedVersions").is_none());
}
#[test]
fn a_known_family_at_an_unknown_version_names_what_is_served() {
let real = DISPATCHED_URIS
.first()
.expect("the dispatcher routes at least one URI");
let family = task_family(real).expect("a task URI has a family");
let bogus = format!("{family}/99.99");
let doc = error_doc(unsupported_type_or_version(&request_doc(), &bogus));
assert_eq!(doc["payload"]["code"], "unsupportedVersion");
let message = doc["payload"]["message"].as_str().expect("a message");
assert!(
message.contains(real),
"the message must name the served version; got {message}"
);
assert_eq!(doc["payload"]["details"]["servedVersions"][0], *real);
assert_eq!(doc["payload"]["details"]["requestedType"], bogus);
}
#[test]
fn a_neighbouring_family_is_not_offered_as_a_version() {
let real = DISPATCHED_URIS
.first()
.expect("the dispatcher routes at least one URI");
let family = task_family(real).expect("a task URI has a family");
let bogus = format!("{family}/99.99");
let doc = error_doc(unsupported_type_or_version(&request_doc(), &bogus));
let served = doc["payload"]["details"]["servedVersions"]
.as_array()
.expect("servedVersions is an array");
for entry in served {
let uri = entry.as_str().expect("a URI string");
assert_eq!(
task_family(uri),
Some(family),
"{uri} is a different family and must not be offered as a version of {family}"
);
}
}
#[test]
fn dispatcher_routes_every_dispatched_uri() {
for uri in DISPATCHED_URIS {
assert!(
spec_policy_for(uri).is_some(),
"`{uri}` is dispatched but the spec registry has no policy for it — either it \
is misspelled, or its specification is not published where \
`trust_tasks_rs::schema_index` can answer `spec_policy_for` for it (or, for a \
task bound ahead of its specification, a local policy stands in for it)"
);
}
}
#[test]
fn rooms_dispatcher_serves_every_wire_uri() {
let served = crate::rooms::handlers::served_uris();
for uri in vti_rooms::wire::ROOMS_DISPATCHED_URIS {
assert!(
served.contains(uri),
"`{uri}` is on the wire but no handler is registered for it — a \
client would be told it is unsupported at every version"
);
}
assert_eq!(
served.len(),
vti_rooms::wire::ROOMS_DISPATCHED_URIS.len(),
"the dispatcher serves something the wire module does not name: {served:?}"
);
}
#[test]
fn dispatched_uris_are_canonical_type_uris() {
for u in DISPATCHED_URIS {
let parsed: Result<trust_tasks_rs::TypeUri, _> = u.parse();
assert!(
parsed.is_ok(),
"dispatched URI is not a canonical TypeUri: {u}"
);
}
}
#[test]
fn member_verbs_are_dispatched() {
for u in [jr::MEMBER_SELF_REMOVE_TYPE, mem::MEMBER_VMC_TYPE] {
assert!(
DISPATCHED_URIS.contains(&u),
"member verb not reachable over TSP: {u}"
);
}
}
#[test]
fn personhood_verbs_are_dispatched() {
for u in [PERSONHOOD_CHALLENGE_TYPE, PERSONHOOD_ASSERT_TYPE] {
assert!(
DISPATCHED_URIS.contains(&u),
"personhood verb not reachable over TSP: {u}"
);
}
}
mod personhood {
use super::*;
use crate::acl::{VtcAclEntry, VtcRole, store_acl_entry};
use crate::test_support::TestVtc;
use serde_json::json;
use vti_rooms_dtg::test_support::Party;
const MEMBER: &str = "did:key:zPersonhoodMember";
async fn fixture() -> TestVtc {
let vtc = TestVtc::builder().with_signers(true).build().await;
seed_member(&vtc, MEMBER).await;
vtc
}
async fn seed_member(vtc: &TestVtc, did: &str) {
store_acl_entry(
&vtc.state.acl_ks,
&VtcAclEntry {
did: did.into(),
role: VtcRole::Member,
label: None,
admin: VtcRole::Member.implied_authority(),
delegated_by: None,
created_at: 0,
created_by: "did:key:vtc-install".into(),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
},
)
.await
.expect("seed member ACL");
}
async fn signed_document(
from: &Party,
type_uri: &str,
payload: serde_json::Value,
) -> Vec<u8> {
let mut doc = vta_sdk::trust_task_sign::build_unsigned(
type_uri,
payload,
&from.did,
crate::test_support::TEST_VTC_DID,
)
.expect("build the document");
let key = vta_sdk::trust_task_sign::HolderKey::from_did_key(
&from.did,
&from.secret_multibase,
)
.expect("a did:key names its own verification method");
vta_sdk::trust_task_sign::sign_in_place_with(&mut doc, &key)
.await
.expect("sign the document");
serde_json::to_vec(&doc).expect("serialize document")
}
fn rendered(out: &TrustTaskOutcome) -> String {
String::from_utf8_lossy(&out.body).into_owned()
}
#[tokio::test]
async fn a_member_can_mint_a_challenge_over_messaging() {
let vtc = fixture().await;
let member = Party::new();
seed_member(&vtc, &member.did).await;
let out = dispatch_trust_task_core(
&vtc.state,
&JoinAuthCtx::didcomm(member.did.clone()),
&signed_document(
&member,
PERSONHOOD_CHALLENGE_TYPE,
json!({ "did": member.did }),
)
.await,
)
.await;
let body = rendered(&out);
assert!(
body.contains("challengeId"),
"expected a challenge in the reply, got: {body}"
);
assert!(
body.contains(crate::members::match_code::MATCH_CODE_EXT_KEY),
"the messaging reply must carry the match code the REST reply does, got: {body}"
);
}
#[tokio::test]
async fn a_success_response_is_signed() {
let vtc = fixture().await;
let member = Party::new();
seed_member(&vtc, &member.did).await;
let out = dispatch_trust_task_core(
&vtc.state,
&JoinAuthCtx::didcomm(member.did.clone()),
&signed_document(
&member,
PERSONHOOD_CHALLENGE_TYPE,
json!({ "did": member.did }),
)
.await,
)
.await;
let doc: serde_json::Value =
serde_json::from_slice(&out.body).expect("the reply is a JSON document");
let proof = doc.get("proof").unwrap_or_else(|| {
panic!(
"a success response carries no proof, so nothing this community says is \
attributable: {}",
rendered(&out)
)
});
assert_eq!(
proof.get("cryptosuite").and_then(|v| v.as_str()),
Some("eddsa-jcs-2022"),
"unexpected cryptosuite: {proof}"
);
assert!(
proof.get("proofValue").and_then(|v| v.as_str()).is_some(),
"the proof carries no signature: {proof}"
);
}
#[tokio::test]
async fn a_community_with_no_signer_still_answers() {
let vtc = TestVtc::builder().with_signers(false).build().await;
let member = Party::new();
seed_member(&vtc, &member.did).await;
let out = dispatch_trust_task_core(
&vtc.state,
&JoinAuthCtx::didcomm(member.did.clone()),
&signed_document(
&member,
PERSONHOOD_CHALLENGE_TYPE,
json!({ "did": member.did }),
)
.await,
)
.await;
assert!(
out.status.is_success(),
"an unsigned answer is still an answer: {}",
rendered(&out)
);
let doc: serde_json::Value =
serde_json::from_slice(&out.body).expect("the reply is a JSON document");
assert!(doc.get("proof").is_none(), "signed without a signer");
}
#[tokio::test]
async fn a_stranger_cannot_mint_a_challenge() {
let vtc = fixture().await;
let stranger = Party::new();
let out = dispatch_trust_task_core(
&vtc.state,
&JoinAuthCtx::didcomm(stranger.did.clone()),
&signed_document(
&stranger,
PERSONHOOD_CHALLENGE_TYPE,
json!({ "did": MEMBER }),
)
.await,
)
.await;
let body = rendered(&out);
assert!(
!body.contains("challengeId"),
"a non-member minted a challenge: {body}"
);
assert!(
body.contains("not a member"),
"expected a permission refusal naming membership, got: {body}"
);
}
#[tokio::test]
async fn a_refusal_is_signed() {
let vtc = fixture().await;
let stranger = Party::new();
let out = dispatch_trust_task_core(
&vtc.state,
&JoinAuthCtx::didcomm(stranger.did.clone()),
&signed_document(
&stranger,
PERSONHOOD_CHALLENGE_TYPE,
json!({ "did": stranger.did }),
)
.await,
)
.await;
assert!(!out.status.is_success(), "{}", rendered(&out));
let doc: serde_json::Value = serde_json::from_slice(&out.body).expect("JSON reply");
assert_eq!(
doc["proof"]["proofPurpose"], "authentication",
"a refusal must carry this community's proof: {doc}"
);
let mut unsigned: serde_json::Value = serde_json::from_slice(
&signed_document(
&stranger,
PERSONHOOD_CHALLENGE_TYPE,
json!({ "did": stranger.did }),
)
.await,
)
.unwrap();
unsigned.as_object_mut().unwrap().remove("proof");
let out = dispatch_trust_task_core(
&vtc.state,
&JoinAuthCtx::didcomm(stranger.did.clone()),
&serde_json::to_vec(&unsigned).unwrap(),
)
.await;
let doc: serde_json::Value = serde_json::from_slice(&out.body).expect("JSON reply");
assert_eq!(doc["proof"]["proofPurpose"], "authentication", "{doc}");
}
#[tokio::test]
async fn one_member_cannot_assert_personhood_for_another() {
let vtc = fixture().await;
let stranger = Party::new();
let out = dispatch_trust_task_core(
&vtc.state,
&JoinAuthCtx::didcomm(stranger.did.clone()),
&signed_document(
&stranger,
PERSONHOOD_ASSERT_TYPE,
json!({
"did": MEMBER,
"presentation": { "type": ["VerifiablePresentation"], "holder": MEMBER },
}),
)
.await,
)
.await;
let body = rendered(&out);
assert!(
body.contains("asserted by its subject"),
"expected the subject-binding refusal, got: {body}"
);
}
}
}
#[cfg(test)]
mod join_discovery_tests {
use super::*;
fn ctx(sender: Option<&str>, signer: Option<&str>) -> JoinAuthCtx {
JoinAuthCtx {
transport: JoinTransport::Rest,
sender_did: sender.map(str::to_string),
verified_signer: signer.map(str::to_string),
}
}
#[test]
fn either_transports_proof_of_who_is_asking_counts() {
assert!(caller_is_identified(&ctx(None, Some("did:key:zSigner"))));
assert!(caller_is_identified(&ctx(Some("did:peer:sender"), None)));
assert!(caller_is_identified(&ctx(
Some("did:peer:sender"),
Some("did:key:zSigner")
)));
}
#[test]
fn a_caller_that_proves_nothing_is_unidentified() {
assert!(!caller_is_identified(&ctx(None, None)));
}
}
#[cfg(test)]
mod members_admin_tests {
use super::*;
use crate::acl::{VtcAclEntry, VtcRole, get_acl_entry, store_acl_entry};
use crate::members::{Member, get_member, store_member};
use crate::test_support::{TEST_VTC_DID, TestVtc};
use serde_json::json;
use vti_rooms_dtg::test_support::Party;
const TARGET: &str = "did:key:zTargetMember";
struct Fixture {
vtc: TestVtc,
admin: Party,
scoped_admin: Party,
member: Party,
}
pub(super) async fn seed_acl(vtc: &TestVtc, did: &str, role: VtcRole, contexts: Vec<String>) {
store_acl_entry(
&vtc.state.acl_ks,
&VtcAclEntry {
did: did.into(),
admin: crate::acl::legacy_seed_authority(&role, &contexts),
delegated_by: None,
role,
label: None,
created_at: 0,
created_by: "did:key:vtc-install".into(),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
},
)
.await
.expect("seed ACL row");
}
async fn fixture() -> Fixture {
let vtc = TestVtc::builder()
.with_audit(true)
.with_signers(true)
.build()
.await;
crate::policy::default::install_defaults(
&vtc.state.policies_ks,
&vtc.state.active_policies_ks,
)
.await
.expect("install default policies");
let admin = Party::new();
let scoped_admin = Party::new();
let member = Party::new();
seed_acl(&vtc, &admin.did, VtcRole::Admin, vec![]).await;
seed_acl(
&vtc,
&scoped_admin.did,
VtcRole::Admin,
vec!["ctx-a".to_string()],
)
.await;
seed_acl(&vtc, &member.did, VtcRole::Member, vec![]).await;
seed_acl(&vtc, TARGET, VtcRole::Member, vec![]).await;
store_member(&vtc.state.members_ks, &Member::fresh(TARGET))
.await
.expect("seed target member");
Fixture {
vtc,
admin,
scoped_admin,
member,
}
}
pub(super) fn unsigned(from: &Party, type_uri: &str, payload: Value) -> TrustTask<Value> {
vta_sdk::trust_task_sign::build_unsigned(type_uri, payload, &from.did, TEST_VTC_DID)
.expect("build the document")
}
pub(super) async fn sign(from: &Party, mut doc: TrustTask<Value>) -> TrustTask<Value> {
let key =
vta_sdk::trust_task_sign::HolderKey::from_did_key(&from.did, &from.secret_multibase)
.expect("a did:key names its own verification method");
vta_sdk::trust_task_sign::sign_in_place_with(&mut doc, &key)
.await
.expect("sign the document");
doc
}
pub(super) async fn signed(from: &Party, type_uri: &str, payload: Value) -> TrustTask<Value> {
sign(from, unsigned(from, type_uri, payload)).await
}
pub(super) async fn dispatch(vtc: &TestVtc, doc: &TrustTask<Value>) -> TrustTaskOutcome {
let body = serde_json::to_vec(doc).expect("a document serialises");
dispatch_trust_task_core(&vtc.state, &JoinAuthCtx::rest(), &body).await
}
pub(super) async fn dispatch_didcomm(
vtc: &TestVtc,
doc: &TrustTask<Value>,
) -> TrustTaskOutcome {
let body = serde_json::to_vec(doc).expect("a document serialises");
let sender = doc
.issuer
.clone()
.expect("the test document names its issuer");
dispatch_trust_task_core(&vtc.state, &JoinAuthCtx::didcomm(sender), &body).await
}
pub(super) fn body_of(out: &TrustTaskOutcome) -> Value {
serde_json::from_slice(&out.body).unwrap_or(Value::Null)
}
pub(super) fn error_code(out: &TrustTaskOutcome) -> Option<String> {
body_of(out)
.pointer("/payload/code")?
.as_str()
.map(str::to_string)
}
pub(super) fn payload_of(out: &TrustTaskOutcome) -> Value {
body_of(out)
.pointer("/payload")
.cloned()
.unwrap_or(Value::Null)
}
pub(super) fn assert_conforms<R: trust_tasks_rs::validate::ValidatedPayload>(
out: &TrustTaskOutcome,
) {
let payload = payload_of(out);
R::validate_value(&payload).unwrap_or_else(|e| {
panic!("response does not match its published schema: {e}\n{payload}")
});
}
#[test]
fn every_moved_task_declares_the_proof_these_tests_assume() {
for uri in [
MEMBER_CREDENTIALS_TYPE,
MEMBER_UPDATE_TYPE,
MEMBER_ADMIN_REMOVE_TYPE,
MEMBER_PURGE_TYPE,
] {
let policy = trust_tasks_rs::schema_index::spec_policy_for(uri)
.unwrap_or_else(|| panic!("{uri} has no published spec policy"));
assert!(
policy.is_proof_required,
"{uri} no longer declares proof REQUIRED — these tests now assert \
nothing, and the design note should be re-read"
);
}
}
#[tokio::test]
async fn vti_ops_020_a_signed_admin_document_reads_member_credentials() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
MEMBER_CREDENTIALS_TYPE,
json!({ "did": TARGET }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"a signed admin document must be accepted: {}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["did"], TARGET);
assert_eq!(payload_of(&out)["memberVmcBound"], false);
assert_conforms::<member_credentials::Response>(&out);
}
#[tokio::test]
async fn vti_ops_020_an_unsigned_admin_document_is_refused() {
let fix = fixture().await;
for uri in [
MEMBER_CREDENTIALS_TYPE,
MEMBER_UPDATE_TYPE,
MEMBER_ADMIN_REMOVE_TYPE,
MEMBER_PURGE_TYPE,
] {
let doc = unsigned(&fix.admin, uri, json!({ "did": TARGET }));
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("proofRequired"),
"{uri}: SPEC §7.2 item 7 names the code: {}",
String::from_utf8_lossy(&out.body)
);
}
assert!(
get_member(&fix.vtc.state.members_ks, TARGET)
.await
.expect("read member")
.is_some(),
"an unsigned document must not have had an effect"
);
}
#[tokio::test]
async fn a_non_admin_signer_is_refused_every_admin_member_verb() {
let fix = fixture().await;
for uri in [
MEMBER_CREDENTIALS_TYPE,
MEMBER_UPDATE_TYPE,
MEMBER_ADMIN_REMOVE_TYPE,
MEMBER_PURGE_TYPE,
] {
let doc = signed(&fix.member, uri, json!({ "did": TARGET })).await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"{uri}: a member is not an administrator: {}",
String::from_utf8_lossy(&out.body)
);
}
}
#[tokio::test]
async fn a_signer_with_no_acl_row_is_refused() {
let fix = fixture().await;
let stranger = Party::new();
let doc = signed(&stranger, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(error_code(&out).as_deref(), Some("permissionDenied"));
}
#[tokio::test]
async fn an_expired_admin_acl_row_no_longer_authorizes() {
let fix = fixture().await;
let lapsed = Party::new();
store_acl_entry(
&fix.vtc.state.acl_ks,
&VtcAclEntry {
did: lapsed.did.clone(),
role: VtcRole::Admin,
label: None,
admin: VtcRole::Admin.implied_authority(),
delegated_by: None,
created_at: 0,
created_by: "did:key:vtc-install".into(),
updated_at: None,
updated_by: None,
expires_at: Some(1),
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
},
)
.await
.expect("seed a lapsed admin row");
let doc = signed(&lapsed, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(error_code(&out).as_deref(), Some("permissionDenied"));
}
async fn auditor(vtc: &TestVtc) -> Party {
let p = Party::new();
store_acl_entry(
&vtc.state.acl_ks,
&VtcAclEntry {
admin: crate::acl::AdminAuthority::for_role(crate::acl::AdminRole::Auditor),
..VtcAclEntry::new(
&p.did,
VtcRole::Member,
crate::acl::AdminAuthority::none(),
"test",
)
},
)
.await
.expect("seed an auditor");
p
}
#[tokio::test]
async fn vti_acl_030_purge_needs_vtc_members_manage() {
let fix = fixture().await;
let auditor = auditor(&fix.vtc).await;
let doc = signed(&auditor, MEMBER_PURGE_TYPE, json!({ "did": TARGET })).await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"purge takes vtc.members.manage: {}",
String::from_utf8_lossy(&out.body)
);
assert!(
get_member(&fix.vtc.state.members_ks, TARGET)
.await
.expect("read member")
.is_some(),
"the refused purge must not have erased the member"
);
}
#[tokio::test]
async fn a_context_scoped_admin_may_still_read_credentials() {
let fix = fixture().await;
let doc = signed(
&fix.scoped_admin,
MEMBER_CREDENTIALS_TYPE,
json!({ "did": TARGET }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"a context-scoped admin passes AdminAuth and must pass here: {}",
String::from_utf8_lossy(&out.body)
);
}
async fn delegate(vtc: &TestVtc, console: &Party, admin_did: &str) {
crate::acl::console_key::enrol_delegation(
&vtc.state.console_keys_ks,
&vtc.state.acl_ks,
&console.did,
admin_did,
Some("test browser".into()),
None,
)
.await
.expect("enrol the delegation");
}
#[tokio::test]
async fn a_delegated_console_key_acts_as_its_admin() {
let fix = fixture().await;
let console = Party::new();
delegate(&fix.vtc, &console, &fix.admin.did).await;
let doc = signed(&console, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"a delegated console key must authorize as its admin: {}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["did"], TARGET);
}
#[tokio::test]
async fn a_revoked_delegation_stops_authorizing_immediately() {
let fix = fixture().await;
let console = Party::new();
delegate(&fix.vtc, &console, &fix.admin.did).await;
let first = signed(&console, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
assert!(dispatch(&fix.vtc, &first).await.status.is_success());
crate::acl::console_key::revoke_delegation(
&fix.vtc.state.console_keys_ks,
&console.did,
&fix.admin.did,
)
.await
.expect("revoke");
let second = signed(&console, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
let out = dispatch(&fix.vtc, &second).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"a revoked console key must be refused: {}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn removing_the_admins_acl_row_stops_the_delegated_key() {
let fix = fixture().await;
let console = Party::new();
delegate(&fix.vtc, &console, &fix.admin.did).await;
crate::acl::delete_acl_entry(&fix.vtc.state.acl_ks, &fix.admin.did)
.await
.expect("remove the admin row");
let doc = signed(&console, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
assert_eq!(
error_code(&dispatch(&fix.vtc, &doc).await).as_deref(),
Some("permissionDenied")
);
}
#[tokio::test]
async fn demoting_the_admin_stops_the_delegated_key() {
let fix = fixture().await;
let console = Party::new();
delegate(&fix.vtc, &console, &fix.admin.did).await;
seed_acl(&fix.vtc, &fix.admin.did, VtcRole::Member, vec![]).await;
let doc = signed(&console, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
assert_eq!(
error_code(&dispatch(&fix.vtc, &doc).await).as_deref(),
Some("permissionDenied")
);
}
#[tokio::test]
async fn an_expired_admin_row_stops_the_delegated_key() {
let fix = fixture().await;
let console = Party::new();
delegate(&fix.vtc, &console, &fix.admin.did).await;
store_acl_entry(
&fix.vtc.state.acl_ks,
&VtcAclEntry {
did: fix.admin.did.clone(),
role: VtcRole::Admin,
label: None,
admin: VtcRole::Admin.implied_authority(),
delegated_by: None,
created_at: 0,
created_by: "did:key:vtc-install".into(),
updated_at: None,
updated_by: None,
expires_at: Some(1),
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
},
)
.await
.expect("expire the admin row");
let doc = signed(&console, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
assert_eq!(
error_code(&dispatch(&fix.vtc, &doc).await).as_deref(),
Some("permissionDenied")
);
}
#[tokio::test]
async fn a_delegation_inherits_its_admins_ceiling_and_no_more() {
let fix = fixture().await;
let auditor = auditor(&fix.vtc).await;
let console = Party::new();
delegate(&fix.vtc, &console, &auditor.did).await;
let purge = signed(&console, MEMBER_PURGE_TYPE, json!({ "did": TARGET })).await;
assert_eq!(
error_code(&dispatch(&fix.vtc, &purge).await).as_deref(),
Some("permissionDenied"),
"purge takes vtc.members.manage, delegation or not"
);
let moderating = Party::new();
delegate(&fix.vtc, &moderating, &fix.scoped_admin.did).await;
let read = signed(
&moderating,
MEMBER_CREDENTIALS_TYPE,
json!({ "did": TARGET }),
)
.await;
assert!(dispatch(&fix.vtc, &read).await.status.is_success());
}
#[tokio::test]
async fn a_signer_holding_its_own_row_is_never_answered_by_a_delegation() {
let fix = fixture().await;
let console = Party::new();
delegate(&fix.vtc, &console, &fix.admin.did).await;
seed_acl(&fix.vtc, &console.did, VtcRole::Member, vec![]).await;
let doc = signed(&console, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
assert_eq!(
error_code(&dispatch(&fix.vtc, &doc).await).as_deref(),
Some("permissionDenied"),
"the row is the answer, and it says no"
);
}
#[tokio::test]
async fn an_undelegated_signer_is_refused_indistinguishably() {
let fix = fixture().await;
let stranger = Party::new();
let doc = signed(&stranger, MEMBER_CREDENTIALS_TYPE, json!({ "did": TARGET })).await;
assert_eq!(
error_code(&dispatch(&fix.vtc, &doc).await).as_deref(),
Some("permissionDenied")
);
}
#[tokio::test]
async fn an_unknown_member_is_the_declared_not_found() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
MEMBER_CREDENTIALS_TYPE,
json!({ "did": "did:key:zNobodyAtAll" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some(crate::routes::members::credentials::MEMBER_CREDENTIALS_ERR_NOT_FOUND),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn update_still_refuses_promotion_to_admin() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
MEMBER_UPDATE_TYPE,
json!({ "did": TARGET, "role": "admin" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some(crate::routes::members::update::UPDATE_ERR_ADMIN_ROLE_FORBIDDEN),
"{}",
String::from_utf8_lossy(&out.body)
);
let acl = get_acl_entry(&fix.vtc.state.acl_ks, TARGET)
.await
.expect("read ACL")
.expect("the target still has a row");
assert_eq!(acl.role, VtcRole::Member, "and nothing was changed");
}
#[tokio::test]
async fn update_refuses_to_grant_registry_consent() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
MEMBER_UPDATE_TYPE,
json!({ "did": TARGET, "publishConsent": true, "label": "Ada Lovelace" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some(crate::routes::members::update::UPDATE_ERR_CONSENT_GRANT_FORBIDDEN),
"{}",
String::from_utf8_lossy(&out.body)
);
let member = get_member(&fix.vtc.state.members_ks, TARGET)
.await
.expect("read member")
.expect("row");
assert!(!member.publish_consent, "consent was not granted");
let acl = get_acl_entry(&fix.vtc.state.acl_ks, TARGET)
.await
.expect("read ACL")
.expect("row");
assert_eq!(
acl.label, None,
"and nothing else in the request was applied"
);
}
#[tokio::test]
async fn update_withdraws_registry_consent_and_accepts_a_no_op() {
let fix = fixture().await;
let mut member = Member::fresh(TARGET);
member.publish_consent = true;
store_member(&fix.vtc.state.members_ks, &member)
.await
.expect("seed consent");
let doc = signed(
&fix.admin,
MEMBER_UPDATE_TYPE,
json!({ "did": TARGET, "publishConsent": true }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let doc = signed(
&fix.admin,
MEMBER_UPDATE_TYPE,
json!({ "did": TARGET, "publishConsent": false }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let after = get_member(&fix.vtc.state.members_ks, TARGET)
.await
.expect("read member")
.expect("row");
assert!(!after.publish_consent, "consent was withdrawn");
let doc = signed(
&fix.admin,
MEMBER_UPDATE_TYPE,
json!({ "did": TARGET, "publishConsent": false }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn update_applies_a_metadata_change_from_a_signed_document() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
MEMBER_UPDATE_TYPE,
json!({ "did": TARGET, "label": "Ada Lovelace", "departurePreference": "purge" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_conforms::<member_update::Response>(&out);
let acl = get_acl_entry(&fix.vtc.state.acl_ks, TARGET)
.await
.expect("read ACL")
.expect("row");
assert_eq!(acl.label.as_deref(), Some("Ada Lovelace"));
assert_eq!(acl.updated_by.as_deref(), Some(fix.admin.did.as_str()));
}
#[tokio::test]
async fn an_omitted_extensions_member_does_not_clear_the_bag() {
let fix = fixture().await;
let mut member = Member::fresh(TARGET);
member.extensions = json!({ "org": "acme" });
store_member(&fix.vtc.state.members_ks, &member)
.await
.expect("seed extensions");
let doc = signed(
&fix.admin,
MEMBER_UPDATE_TYPE,
json!({ "did": TARGET, "departurePreference": "purge" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let after = get_member(&fix.vtc.state.members_ks, TARGET)
.await
.expect("read member")
.expect("row");
assert_eq!(after.extensions, json!({ "org": "acme" }));
}
#[tokio::test]
async fn admin_remove_departs_the_member_from_a_signed_document() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
MEMBER_ADMIN_REMOVE_TYPE,
json!({ "did": TARGET, "reason": "ToS violation" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["did"], TARGET);
assert_eq!(payload_of(&out)["removed"], true);
assert_conforms::<member_admin_remove::Response>(&out);
assert!(
get_acl_entry(&fix.vtc.state.acl_ks, TARGET)
.await
.expect("read ACL")
.is_none(),
"the departed member keeps no authorization"
);
}
#[tokio::test]
async fn purge_erases_the_member_from_a_signed_document() {
let fix = fixture().await;
let doc = signed(&fix.admin, MEMBER_PURGE_TYPE, json!({ "did": TARGET })).await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_conforms::<member_purge::Response>(&out);
assert!(
get_member(&fix.vtc.state.members_ks, TARGET)
.await
.expect("read member")
.is_none(),
"purge erases the row"
);
}
#[tokio::test]
async fn vti_ops_025_a_redelivered_document_is_answered_not_re_executed() {
let fix = fixture().await;
let doc = signed(&fix.admin, MEMBER_PURGE_TYPE, json!({ "did": TARGET })).await;
let first = dispatch(&fix.vtc, &doc).await;
assert!(
first.status.is_success(),
"{}",
String::from_utf8_lossy(&first.body)
);
let second = dispatch(&fix.vtc, &doc).await;
assert!(
second.status.is_success(),
"the redelivery must be answered, not refused: {}",
String::from_utf8_lossy(&second.body)
);
assert_eq!(
payload_of(&second),
payload_of(&first),
"the recorded outcome is what a redelivery is answered with"
);
}
#[tokio::test]
async fn vti_ops_026_a_different_document_under_a_spent_id_conflicts() {
let fix = fixture().await;
let second_target = "did:key:zSecondTarget";
seed_acl(&fix.vtc, second_target, VtcRole::Member, vec![]).await;
store_member(&fix.vtc.state.members_ks, &Member::fresh(second_target))
.await
.expect("seed second member");
let first = signed(&fix.admin, MEMBER_PURGE_TYPE, json!({ "did": TARGET })).await;
let out = dispatch(&fix.vtc, &first).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let mut collider = unsigned(
&fix.admin,
MEMBER_PURGE_TYPE,
json!({ "did": second_target }),
);
collider.id.clone_from(&first.id);
let collider = sign(&fix.admin, collider).await;
let out = dispatch(&fix.vtc, &collider).await;
assert_eq!(
error_code(&out).as_deref(),
Some("idConflict"),
"{}",
String::from_utf8_lossy(&out.body)
);
assert!(
get_member(&fix.vtc.state.members_ks, second_target)
.await
.expect("read member")
.is_some(),
"the conflicting document must not have executed"
);
}
#[tokio::test]
async fn vti_ops_027_the_spent_id_is_visible_to_any_binding() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
MEMBER_CREDENTIALS_TYPE,
json!({ "did": TARGET }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let now = chrono::Utc::now();
let again = fix
.vtc
.state
.accepted_ids()
.claim(&doc, retain_until(&doc, now), now)
.await
.expect("the record is readable");
assert!(
matches!(again, accepted_ids::Acceptance::Duplicate { .. }),
"a second binding must see the id the dispatcher spent"
);
}
}
#[cfg(test)]
mod join_decide_profile_tests {
use super::members_admin_tests::{
assert_conforms, dispatch, error_code, payload_of, seed_acl, sign, signed, unsigned,
};
use super::*;
use crate::acl::VtcRole;
use crate::community::{CommunityProfile, load_profile, store_profile};
use crate::join::{JoinRequest, JoinStatus, get_join_request, store_join_request};
use crate::members::get_member;
use crate::test_support::{TEST_VTC_DID, TestVtc};
use serde_json::json;
use vti_rooms_dtg::test_support::Party;
const APPLICANT: &str = "did:key:zApplicant";
const PUBLIC_URL: &str = "https://vtc.example.com";
struct Fixture {
vtc: TestVtc,
admin: Party,
scoped_admin: Party,
member: Party,
}
async fn fixture() -> Fixture {
let vtc = TestVtc::builder()
.with_audit(true)
.with_signers(true)
.with_public_url(PUBLIC_URL)
.build()
.await;
crate::policy::default::install_defaults(
&vtc.state.policies_ks,
&vtc.state.active_policies_ks,
)
.await
.expect("install default policies");
for purpose in [
affinidi_status_list::StatusPurpose::Revocation,
affinidi_status_list::StatusPurpose::Suspension,
] {
crate::status_list::ensure_initial(
&vtc.state.status_lists_ks,
purpose,
format!("{PUBLIC_URL}/v1/status-lists/{purpose}"),
)
.await
.expect("seed the status list");
}
store_profile(
&vtc.state.community_ks,
&CommunityProfile::new(TEST_VTC_DID, "Example Community"),
)
.await
.expect("seed the community profile");
let admin = Party::new();
let scoped_admin = Party::new();
let member = Party::new();
seed_acl(&vtc, &admin.did, VtcRole::Admin, vec![]).await;
seed_acl(
&vtc,
&scoped_admin.did,
VtcRole::Admin,
vec!["ctx-a".to_string()],
)
.await;
seed_acl(&vtc, &member.did, VtcRole::Member, vec![]).await;
Fixture {
vtc,
admin,
scoped_admin,
member,
}
}
async fn pending_request(vtc: &TestVtc) -> uuid::Uuid {
let req = JoinRequest::new(APPLICANT, json!({}));
let id = req.id;
store_join_request(&vtc.state.join_requests_ks, &req)
.await
.expect("seed a pending join request");
id
}
#[test]
fn every_moved_task_declares_the_proof_these_tests_assume() {
for uri in [JOIN_DECIDE_TYPE, COMMUNITY_PROFILE_UPDATE_TYPE] {
let policy = trust_tasks_rs::schema_index::spec_policy_for(uri)
.unwrap_or_else(|| panic!("{uri} has no published spec policy"));
assert!(
policy.is_proof_required,
"{uri} no longer declares proof REQUIRED — these tests now assert \
nothing, and the design note should be re-read"
);
}
}
#[tokio::test]
async fn vti_ops_020_a_signed_admin_document_approves_a_join_request() {
let fix = fixture().await;
let id = pending_request(&fix.vtc).await;
let doc = signed(
&fix.admin,
JOIN_DECIDE_TYPE,
json!({ "id": id.to_string(), "decision": "approved" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"a signed admin document must be accepted: {}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["requestId"], id.to_string());
assert_eq!(payload_of(&out)["status"], "approved");
assert!(
payload_of(&out)["vmc"].is_object(),
"approval delivers the membership credential inline"
);
assert_conforms::<join_decide::Response>(&out);
let member = get_member(&fix.vtc.state.members_ks, APPLICANT)
.await
.expect("read member")
.expect("the applicant is now a member");
assert!(member.current_vmc_id.is_some());
}
#[tokio::test]
async fn vti_ops_020_a_signed_admin_document_updates_the_profile() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({ "name": "Renamed Community", "description": "A better blurb." }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["profile"]["name"], "Renamed Community");
assert_conforms::<community_profile_update::Response>(&out);
let stored = load_profile(&fix.vtc.state.community_ks)
.await
.expect("read profile")
.expect("row");
assert_eq!(stored.name, "Renamed Community");
assert_eq!(stored.description, "A better blurb.");
}
#[tokio::test]
async fn vti_ops_020_an_unsigned_admin_document_is_refused() {
let fix = fixture().await;
let id = pending_request(&fix.vtc).await;
for (uri, payload) in [
(
JOIN_DECIDE_TYPE,
json!({ "id": id.to_string(), "decision": "approved" }),
),
(COMMUNITY_PROFILE_UPDATE_TYPE, json!({ "name": "Hijacked" })),
] {
let doc = unsigned(&fix.admin, uri, payload);
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("proofRequired"),
"{uri}: SPEC §7.2 item 7 names the code: {}",
String::from_utf8_lossy(&out.body)
);
}
assert_eq!(
get_join_request(&fix.vtc.state.join_requests_ks, id)
.await
.expect("read request")
.expect("row")
.status,
JoinStatus::Pending,
"an unsigned decision must not have decided anything"
);
assert_eq!(
load_profile(&fix.vtc.state.community_ks)
.await
.expect("read profile")
.expect("row")
.name,
"Example Community",
);
}
#[tokio::test]
async fn a_non_admin_signer_is_refused_both_verbs() {
let fix = fixture().await;
let id = pending_request(&fix.vtc).await;
for (uri, payload) in [
(
JOIN_DECIDE_TYPE,
json!({ "id": id.to_string(), "decision": "approved" }),
),
(COMMUNITY_PROFILE_UPDATE_TYPE, json!({ "name": "Hijacked" })),
] {
let doc = signed(&fix.member, uri, payload).await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"{uri}: a member is not an administrator: {}",
String::from_utf8_lossy(&out.body)
);
}
assert!(
get_member(&fix.vtc.state.members_ks, APPLICANT)
.await
.expect("read member")
.is_none(),
"the refused decision must not have admitted anybody"
);
}
#[tokio::test]
async fn a_signer_with_no_acl_row_is_refused() {
let fix = fixture().await;
let stranger = Party::new();
let doc = signed(
&stranger,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({ "name": "Hijacked" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(error_code(&out).as_deref(), Some("permissionDenied"));
}
#[tokio::test]
async fn a_moderator_may_decide_and_may_not_edit_the_profile() {
let fix = fixture().await;
let id = pending_request(&fix.vtc).await;
let doc = signed(
&fix.scoped_admin,
JOIN_DECIDE_TYPE,
json!({ "id": id.to_string(), "decision": "rejected", "reason": "not this time" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"a moderator holds vtc.join.decide: {}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["status"], "rejected");
let doc = signed(
&fix.scoped_admin,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({ "name": "Scoped Rename" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"the profile takes vtc.surface.admin: {}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn an_unknown_request_is_the_declared_not_found() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
JOIN_DECIDE_TYPE,
json!({ "id": uuid::Uuid::new_v4().to_string(), "decision": "approved" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some(crate::routes::join_requests::decide::DECIDE_ERR_NOT_FOUND),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn an_id_that_is_not_an_id_is_malformed_not_not_found() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
JOIN_DECIDE_TYPE,
json!({ "id": "not-a-uuid", "decision": "approved" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("malformedRequest"),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn a_second_decision_on_a_decided_request_is_not_pending() {
let fix = fixture().await;
let id = pending_request(&fix.vtc).await;
let first = signed(
&fix.admin,
JOIN_DECIDE_TYPE,
json!({ "id": id.to_string(), "decision": "approved" }),
)
.await;
let out = dispatch(&fix.vtc, &first).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let second = signed(
&fix.admin,
JOIN_DECIDE_TYPE,
json!({ "id": id.to_string(), "decision": "rejected" }),
)
.await;
assert_ne!(first.id, second.id, "these must be distinct documents");
let out = dispatch(&fix.vtc, &second).await;
assert_eq!(
error_code(&out).as_deref(),
Some(crate::routes::join_requests::decide::DECIDE_ERR_NOT_PENDING),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn a_bad_profile_field_is_the_declared_validation_failure() {
let fix = fixture().await;
let doc = signed(
&fix.admin,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({ "logoUrl": "javascript:alert(1)" }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some(crate::routes::community::profile::PROFILE_UPDATE_ERR_VALIDATION_FAILED),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn an_omitted_member_does_not_clear_what_it_did_not_mention() {
let fix = fixture().await;
let set = signed(
&fix.admin,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({
"logoUrl": "https://example.com/logo.png",
"extensions": { "org": "acme" },
}),
)
.await;
assert!(dispatch(&fix.vtc, &set).await.status.is_success());
let other = signed(
&fix.admin,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({ "name": "Still Logoed" }),
)
.await;
assert!(dispatch(&fix.vtc, &other).await.status.is_success());
let stored = load_profile(&fix.vtc.state.community_ks)
.await
.expect("read profile")
.expect("row");
assert_eq!(
stored.logo_url.as_deref(),
Some("https://example.com/logo.png"),
"an omitted `logoUrl` must leave the stored one alone"
);
assert_eq!(
stored.extensions,
json!({ "org": "acme" }),
"and an omitted `extensions` must not clear the bag"
);
}
#[tokio::test]
async fn an_explicit_null_does_not_yet_clear_a_nullable_member() {
let fix = fixture().await;
let set = signed(
&fix.admin,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({ "logoUrl": "https://example.com/logo.png" }),
)
.await;
assert!(dispatch(&fix.vtc, &set).await.status.is_success());
let clear = signed(
&fix.admin,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({ "logoUrl": null }),
)
.await;
let out = dispatch(&fix.vtc, &clear).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(
load_profile(&fix.vtc.state.community_ks)
.await
.expect("read profile")
.expect("row")
.logo_url
.as_deref(),
Some("https://example.com/logo.png"),
"the logo survives a `null` the specification says should clear it"
);
}
#[tokio::test]
async fn vti_ops_025_a_replayed_decision_does_not_issue_a_second_credential() {
let fix = fixture().await;
let id = pending_request(&fix.vtc).await;
let doc = signed(
&fix.admin,
JOIN_DECIDE_TYPE,
json!({ "id": id.to_string(), "decision": "approved" }),
)
.await;
let first = dispatch(&fix.vtc, &doc).await;
assert!(
first.status.is_success(),
"{}",
String::from_utf8_lossy(&first.body)
);
let issued = get_member(&fix.vtc.state.members_ks, APPLICANT)
.await
.expect("read member")
.expect("the applicant is a member");
let first_vmc_id = issued.current_vmc_id.clone().expect("a VMC was issued");
let first_slot = issued.status_list_index;
let second = dispatch(&fix.vtc, &doc).await;
assert!(
second.status.is_success(),
"the redelivery must be answered, not refused: {}",
String::from_utf8_lossy(&second.body)
);
assert_eq!(
payload_of(&second),
payload_of(&first),
"the recorded outcome is what a redelivery is answered with — a \
second execution would have answered `notPending` instead"
);
let after = get_member(&fix.vtc.state.members_ks, APPLICANT)
.await
.expect("read member")
.expect("row");
assert_eq!(
after.current_vmc_id.as_deref(),
Some(first_vmc_id.as_str()),
"the member still holds the credential the first execution issued"
);
assert_eq!(
after.status_list_index, first_slot,
"and no second status-list slot was burned"
);
}
#[tokio::test]
async fn vti_ops_026_a_different_document_under_a_spent_id_conflicts() {
let fix = fixture().await;
let first = signed(
&fix.admin,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({ "name": "First Name" }),
)
.await;
let out = dispatch(&fix.vtc, &first).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let mut collider = unsigned(
&fix.admin,
COMMUNITY_PROFILE_UPDATE_TYPE,
json!({ "name": "Second Name" }),
);
collider.id.clone_from(&first.id);
let collider = sign(&fix.admin, collider).await;
let out = dispatch(&fix.vtc, &collider).await;
assert_eq!(
error_code(&out).as_deref(),
Some("idConflict"),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(
load_profile(&fix.vtc.state.community_ks)
.await
.expect("read profile")
.expect("row")
.name,
"First Name",
"the conflicting document must not have executed"
);
}
}
#[cfg(test)]
mod config_pair_tests {
use super::members_admin_tests::{
assert_conforms, dispatch, error_code, payload_of, seed_acl, signed, unsigned,
};
use super::*;
use crate::acl::VtcRole;
use crate::community::{CommunityProfile, load_profile, store_profile};
use crate::config_store::ConfigStore;
use crate::routes::admin::config::{
IMPORT_ERR_COMMUNITY_DID_MISMATCH, IMPORT_ERR_UNSUPPORTED_SCHEMA_VERSION, export_inner,
};
use crate::test_support::{TEST_VTC_DID, TestVtc};
use serde_json::json;
use vti_rooms_dtg::test_support::Party;
struct Fixture {
vtc: TestVtc,
admin: Party,
scoped_admin: Party,
member: Party,
}
async fn fixture() -> Fixture {
let vtc = TestVtc::builder()
.with_audit(true)
.with_signers(true)
.build()
.await;
store_profile(
&vtc.state.community_ks,
&CommunityProfile::new(TEST_VTC_DID, "Example Community"),
)
.await
.expect("seed the community profile");
let admin = Party::new();
let scoped_admin = Party::new();
let member = Party::new();
seed_acl(&vtc, &admin.did, VtcRole::Admin, vec![]).await;
seed_acl(
&vtc,
&scoped_admin.did,
VtcRole::Admin,
vec!["ctx-a".to_string()],
)
.await;
seed_acl(&vtc, &member.did, VtcRole::Member, vec![]).await;
Fixture {
vtc,
admin,
scoped_admin,
member,
}
}
async fn changed_document(vtc: &TestVtc) -> Value {
let mut document = serde_json::to_value(
export_inner(&vtc.state)
.await
.expect("export the seeded community")
.document,
)
.expect("the document serialises");
document["communityProfile"]["name"] = json!("Imported Community");
document["configOverrides"] = json!({ "log.level": "debug" });
document
}
async fn stored_name(vtc: &TestVtc) -> String {
load_profile(&vtc.state.community_ks)
.await
.expect("read profile")
.expect("row")
.name
}
async fn stored_log_level(vtc: &TestVtc) -> Option<Value> {
ConfigStore::new(vtc.state.config_ks.clone())
.get("log.level")
.await
.expect("read override")
}
#[test]
fn every_moved_task_declares_the_proof_these_tests_assume() {
for uri in [CONFIG_EXPORT_TYPE, CONFIG_IMPORT_TYPE] {
let policy = trust_tasks_rs::schema_index::spec_policy_for(uri)
.unwrap_or_else(|| panic!("{uri} has no published spec policy"));
assert!(
policy.is_proof_required,
"{uri} no longer declares proof REQUIRED — these tests now assert \
nothing, and the design note should be re-read"
);
}
}
#[tokio::test]
async fn vti_ops_020_a_signed_admin_document_exports_the_configuration() {
let fix = fixture().await;
ConfigStore::new(fix.vtc.state.config_ks.clone())
.put("log.level", &json!("warn"))
.await
.expect("seed an override");
let doc = signed(&fix.admin, CONFIG_EXPORT_TYPE, json!({})).await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"a signed admin document must be accepted: {}",
String::from_utf8_lossy(&out.body)
);
let document = &payload_of(&out)["document"];
assert_eq!(document["communityProfile"]["name"], "Example Community");
assert_eq!(document["configOverrides"]["log.level"], "warn");
assert_conforms::<config_export::Response>(&out);
}
#[tokio::test]
async fn vti_ops_020_a_signed_import_previews_by_default_and_writes_nothing() {
let fix = fixture().await;
let document = changed_document(&fix.vtc).await;
let doc = signed(
&fix.admin,
CONFIG_IMPORT_TYPE,
json!({ "document": document }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let payload = payload_of(&out);
assert_eq!(payload["status"], "preview");
assert_eq!(payload["profileChanges"][0]["key"], "name");
assert_eq!(payload["overrideChanges"][0]["key"], "log.level");
assert_conforms::<config_import::Response>(&out);
assert_eq!(stored_name(&fix.vtc).await, "Example Community");
assert_eq!(stored_log_level(&fix.vtc).await, None);
}
#[tokio::test]
async fn vti_ops_020_a_confirmed_signed_import_applies_the_document() {
let fix = fixture().await;
let document = changed_document(&fix.vtc).await;
let doc = signed(
&fix.admin,
CONFIG_IMPORT_TYPE,
json!({ "document": document, "confirm": true }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["status"], "imported");
assert_conforms::<config_import::Response>(&out);
assert_eq!(stored_name(&fix.vtc).await, "Imported Community");
assert_eq!(stored_log_level(&fix.vtc).await, Some(json!("debug")));
}
#[tokio::test]
async fn an_exported_document_imports_as_no_change() {
let fix = fixture().await;
let export = signed(&fix.admin, CONFIG_EXPORT_TYPE, json!({})).await;
let out = dispatch(&fix.vtc, &export).await;
assert!(out.status.is_success());
let document = payload_of(&out)["document"].clone();
let import = signed(
&fix.admin,
CONFIG_IMPORT_TYPE,
json!({ "document": document, "confirm": true }),
)
.await;
let out = dispatch(&fix.vtc, &import).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let payload = payload_of(&out);
assert_eq!(payload["profileChanges"], json!([]), "{payload}");
assert_eq!(payload["overrideChanges"], json!([]), "{payload}");
assert_eq!(payload["rejected"], json!([]), "{payload}");
}
#[tokio::test]
async fn vti_ops_020_an_unsigned_config_document_is_refused() {
let fix = fixture().await;
let document = changed_document(&fix.vtc).await;
for (uri, payload) in [
(CONFIG_EXPORT_TYPE, json!({})),
(
CONFIG_IMPORT_TYPE,
json!({ "document": document, "confirm": true }),
),
] {
let doc = unsigned(&fix.admin, uri, payload);
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("proofRequired"),
"{uri}: SPEC §7.2 item 7 names the code: {}",
String::from_utf8_lossy(&out.body)
);
}
assert_eq!(stored_name(&fix.vtc).await, "Example Community");
}
#[tokio::test]
async fn a_signer_who_is_not_an_admin_is_refused_both_verbs() {
let fix = fixture().await;
let stranger = Party::new();
let document = changed_document(&fix.vtc).await;
for signer in [&fix.member, &stranger] {
for (uri, payload) in [
(CONFIG_EXPORT_TYPE, json!({})),
(
CONFIG_IMPORT_TYPE,
json!({ "document": document, "confirm": true }),
),
] {
let doc = signed(signer, uri, payload).await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"{uri}: {}",
String::from_utf8_lossy(&out.body)
);
}
}
assert_eq!(stored_name(&fix.vtc).await, "Example Community");
assert_eq!(stored_log_level(&fix.vtc).await, None);
}
#[tokio::test]
async fn vti_acl_030_config_export_and_import_need_vtc_config_admin() {
let fix = fixture().await;
let document = changed_document(&fix.vtc).await;
let doc = signed(&fix.scoped_admin, CONFIG_EXPORT_TYPE, json!({})).await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"export takes vtc.config.admin: {}",
String::from_utf8_lossy(&out.body)
);
let doc = signed(
&fix.scoped_admin,
CONFIG_IMPORT_TYPE,
json!({ "document": document, "confirm": true }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_ne!(stored_name(&fix.vtc).await, "Imported Community");
}
#[tokio::test]
async fn another_communitys_document_is_the_declared_mismatch() {
let fix = fixture().await;
let mut document = changed_document(&fix.vtc).await;
document["communityProfile"]["communityDid"] = json!("did:web:elsewhere.example");
let doc = signed(
&fix.admin,
CONFIG_IMPORT_TYPE,
json!({ "document": document, "confirm": true }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some(IMPORT_ERR_COMMUNITY_DID_MISMATCH),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(stored_name(&fix.vtc).await, "Example Community");
}
#[tokio::test]
async fn an_unknown_schema_version_is_the_declared_refusal() {
let fix = fixture().await;
let mut document = changed_document(&fix.vtc).await;
document["schemaVersion"] = json!(2);
let doc = signed(
&fix.admin,
CONFIG_IMPORT_TYPE,
json!({ "document": document }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some(IMPORT_ERR_UNSUPPORTED_SCHEMA_VERSION),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn the_published_ext_members_are_accepted() {
let fix = fixture().await;
let mut document = changed_document(&fix.vtc).await;
document["ext"] = json!({ "org.example": { "note": "from staging" } });
let doc = signed(
&fix.admin,
CONFIG_IMPORT_TYPE,
json!({
"document": document,
"ext": { "org.example": { "ticket": "OPS-1" } },
}),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"a schema-valid document must not be refused as malformed: {}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["status"], "preview");
}
}
#[cfg(test)]
mod endorsement_type_tests {
use super::members_admin_tests::{
assert_conforms, dispatch, error_code, payload_of, seed_acl, signed, unsigned,
};
use super::*;
use crate::acl::VtcRole;
use crate::endorsement_types::get_type;
use crate::routes::endorsement_types::{
CLAIM_SCHEMA_MAX_BYTES, DELETE_ERR_IN_USE, DELETE_ERR_NOT_FOUND, REGISTER_ERR_EXISTS,
REGISTER_ERR_RESERVED,
};
use crate::test_support::TestVtc;
use serde_json::json;
use vti_rooms_dtg::test_support::Party;
const TYPE: &str = "https://example.org/predicates/vetted/1";
struct Fixture {
vtc: TestVtc,
admin: Party,
scoped_admin: Party,
member: Party,
}
async fn fixture() -> Fixture {
let vtc = TestVtc::builder()
.with_audit(true)
.with_signers(true)
.build()
.await;
let admin = Party::new();
let scoped_admin = Party::new();
let member = Party::new();
seed_acl(&vtc, &admin.did, VtcRole::Admin, vec![]).await;
seed_acl(
&vtc,
&scoped_admin.did,
VtcRole::Admin,
vec!["ctx-a".to_string()],
)
.await;
seed_acl(&vtc, &member.did, VtcRole::Member, vec![]).await;
Fixture {
vtc,
admin,
scoped_admin,
member,
}
}
async fn register(fix: &Fixture, from: &Party, payload: Value) -> TrustTaskOutcome {
let doc = signed(from, ENDORSEMENT_TYPE_REGISTER_TYPE, payload).await;
dispatch(&fix.vtc, &doc).await
}
async fn delete(fix: &Fixture, from: &Party, type_uri: &str) -> TrustTaskOutcome {
let doc = signed(
from,
ENDORSEMENT_TYPE_DELETE_TYPE,
json!({ "typeUri": type_uri }),
)
.await;
dispatch(&fix.vtc, &doc).await
}
async fn stored(fix: &Fixture) -> Option<crate::endorsement_types::EndorsementType> {
get_type(&fix.vtc.state.endorsement_types_ks, TYPE)
.await
.expect("read type")
}
#[test]
fn every_moved_task_declares_the_proof_these_tests_assume() {
for uri in [ENDORSEMENT_TYPE_REGISTER_TYPE, ENDORSEMENT_TYPE_DELETE_TYPE] {
let policy = trust_tasks_rs::schema_index::spec_policy_for(uri)
.unwrap_or_else(|| panic!("{uri} has no published spec policy"));
assert!(
policy.is_proof_required,
"{uri} no longer declares proof REQUIRED — these tests now assert \
nothing, and the design note should be re-read"
);
}
}
#[tokio::test]
async fn vti_ops_020_a_signed_admin_document_registers_a_type() {
let fix = fixture().await;
let out = register(
&fix,
&fix.admin,
json!({
"typeUri": TYPE,
"description": "Peer identity vetting",
"claimSchema": { "type": "object" },
}),
)
.await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["endorsementType"]["typeUri"], TYPE);
assert_conforms::<endorsement_type_register::Response>(&out);
let row = stored(&fix).await.expect("the type is registered");
assert_eq!(row.created_by_did, fix.admin.did);
assert_eq!(row.claim_schema, Some(json!({ "type": "object" })));
}
#[tokio::test]
async fn vti_ops_020_a_signed_admin_document_deletes_a_type() {
let fix = fixture().await;
assert!(
register(&fix, &fix.admin, json!({ "typeUri": TYPE }))
.await
.status
.is_success()
);
let out = delete(&fix, &fix.admin, TYPE).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
assert_eq!(payload_of(&out)["typeUri"], TYPE);
assert_conforms::<endorsement_type_delete::Response>(&out);
assert!(stored(&fix).await.is_none());
}
#[tokio::test]
async fn vti_ops_020_an_unsigned_document_is_refused() {
let fix = fixture().await;
for (uri, payload) in [
(ENDORSEMENT_TYPE_REGISTER_TYPE, json!({ "typeUri": TYPE })),
(ENDORSEMENT_TYPE_DELETE_TYPE, json!({ "typeUri": TYPE })),
] {
let out = dispatch(&fix.vtc, &unsigned(&fix.admin, uri, payload)).await;
assert_eq!(
error_code(&out).as_deref(),
Some("proofRequired"),
"{uri}: {}",
String::from_utf8_lossy(&out.body)
);
}
assert!(stored(&fix).await.is_none());
}
#[tokio::test]
async fn a_signer_who_is_not_an_admin_is_refused_both_verbs() {
let fix = fixture().await;
let stranger = Party::new();
for signer in [&fix.member, &stranger] {
let out = register(&fix, signer, json!({ "typeUri": TYPE })).await;
assert_eq!(error_code(&out).as_deref(), Some("permissionDenied"));
let out = delete(&fix, signer, TYPE).await;
assert_eq!(error_code(&out).as_deref(), Some("permissionDenied"));
}
assert!(stored(&fix).await.is_none());
}
#[tokio::test]
async fn vti_acl_030_endorsement_types_need_vtc_surface_admin() {
let fix = fixture().await;
let out = register(&fix, &fix.scoped_admin, json!({ "typeUri": TYPE })).await;
assert_eq!(error_code(&out).as_deref(), Some("permissionDenied"));
let out = delete(&fix, &fix.scoped_admin, TYPE).await;
assert_eq!(error_code(&out).as_deref(), Some("permissionDenied"));
}
#[tokio::test]
async fn the_declared_register_refusals_are_carried_as_codes() {
let fix = fixture().await;
let out = register(
&fix,
&fix.admin,
json!({ "typeUri": crate::endorsements::VETTER_GRANT_ROW_TYPE }),
)
.await;
assert_eq!(error_code(&out).as_deref(), Some(REGISTER_ERR_RESERVED));
assert!(
register(&fix, &fix.admin, json!({ "typeUri": TYPE }))
.await
.status
.is_success()
);
let out = register(&fix, &fix.admin, json!({ "typeUri": TYPE })).await;
assert_eq!(error_code(&out).as_deref(), Some(REGISTER_ERR_EXISTS));
}
#[tokio::test]
async fn a_claim_schema_over_the_cap_is_refused() {
let fix = fixture().await;
let padding = "x".repeat(CLAIM_SCHEMA_MAX_BYTES);
let out = register(
&fix,
&fix.admin,
json!({
"typeUri": TYPE,
"claimSchema": { "type": "object", "description": padding },
}),
)
.await;
assert_eq!(
error_code(&out).as_deref(),
Some("malformedRequest"),
"{}",
String::from_utf8_lossy(&out.body)
);
assert!(stored(&fix).await.is_none());
}
#[tokio::test]
async fn a_claim_schema_that_is_not_a_schema_is_malformed_not_a_panic() {
let fix = fixture().await;
let out = register(
&fix,
&fix.admin,
json!({ "typeUri": TYPE, "claimSchema": { "type": "not-a-type" } }),
)
.await;
assert_eq!(
error_code(&out).as_deref(),
Some("malformedRequest"),
"{}",
String::from_utf8_lossy(&out.body)
);
assert!(stored(&fix).await.is_none());
}
#[tokio::test]
async fn an_omitted_claim_schema_is_stored_as_absent() {
let fix = fixture().await;
assert!(
register(&fix, &fix.admin, json!({ "typeUri": TYPE }))
.await
.status
.is_success()
);
assert_eq!(stored(&fix).await.expect("row").claim_schema, None);
}
#[tokio::test]
async fn deleting_an_unknown_type_is_the_declared_not_found() {
let fix = fixture().await;
let out = delete(&fix, &fix.admin, TYPE).await;
assert_eq!(error_code(&out).as_deref(), Some(DELETE_ERR_NOT_FOUND));
}
#[tokio::test]
async fn deleting_a_referenced_type_is_the_declared_in_use() {
let fix = fixture().await;
assert!(
register(&fix, &fix.admin, json!({ "typeUri": TYPE }))
.await
.status
.is_success()
);
let schemas_ks = &fix.vtc.state.schemas_ks;
let entry: crate::schemas::SchemaEntry = serde_json::from_value(json!({
"typeUri": "StatementCredential",
"dtgType": "StatementCredential",
"kind": "accepts",
"createdAt": "2026-09-24T00:00:00Z",
"createdByDid": fix.admin.did,
}))
.expect("schema entry");
crate::schemas::storage::store_schema(schemas_ks, &entry)
.await
.expect("store the referenced schema");
let criterion: crate::schemas::accepts::AcceptsCriterion = serde_json::from_value(json!({
"id": "vetted",
"admission": "automatic",
"vetting": {
"version": "0.1",
"statementType": TYPE,
"minStatements": 2,
"minByMethod": { "inPerson": 1 },
"acceptedMethods": ["inPerson", "video"],
"maxStatementAge": "P120D",
"eligibleVetters": { "role": "vetter" },
},
"createdAt": "2026-09-24T00:00:00Z",
"createdByDid": fix.admin.did,
}))
.expect("criterion");
crate::schemas::accepts::store_accepts(schemas_ks, &criterion)
.await
.expect("store the criterion");
let out = delete(&fix, &fix.admin, TYPE).await;
assert_eq!(
error_code(&out).as_deref(),
Some(DELETE_ERR_IN_USE),
"{}",
String::from_utf8_lossy(&out.body)
);
assert!(stored(&fix).await.is_some());
}
}
#[cfg(test)]
mod backup_export_tests {
use super::members_admin_tests::{
assert_conforms, dispatch, dispatch_didcomm, error_code, payload_of, seed_acl, signed,
unsigned,
};
use super::*;
use crate::acl::VtcRole;
use crate::test_support::{TEST_VTC_DID, TestVtc};
use serde_json::json;
use vti_rooms_dtg::test_support::Party;
const PASSWORD: &str = "a-long-enough-backup-password";
struct Fixture {
vtc: TestVtc,
super_admin: Party,
scoped_admin: Party,
member: Party,
}
async fn fixture() -> Fixture {
let vtc = TestVtc::builder()
.vtc_did(TEST_VTC_DID)
.with_audit(true)
.with_signers(true)
.build()
.await;
let store = {
let mut config = vtc.state.config.write().await;
config.secrets.backend = Some(crate::config::SecretBackend::Plaintext);
crate::keys::seed_store::create_secret_store(&config).expect("plaintext store")
};
store
.set(b"signing-bundle")
.await
.expect("seed the secret store");
let super_admin = Party::new();
let scoped_admin = Party::new();
let member = Party::new();
seed_acl(&vtc, &super_admin.did, VtcRole::Admin, vec![]).await;
seed_acl(
&vtc,
&scoped_admin.did,
VtcRole::Admin,
vec!["ctx-a".to_string()],
)
.await;
seed_acl(&vtc, &member.did, VtcRole::Member, vec![]).await;
Fixture {
vtc,
super_admin,
scoped_admin,
member,
}
}
#[test]
fn the_moved_task_declares_the_proof_these_tests_assume() {
let policy = trust_tasks_rs::schema_index::spec_policy_for(BACKUP_EXPORT_TYPE)
.expect("backup/export has a published spec policy");
assert!(
policy.is_proof_required,
"backup/export no longer declares proof REQUIRED — these tests now \
assert nothing, and the design note should be re-read"
);
}
#[tokio::test]
async fn vti_ops_020_a_signed_super_admin_document_exports() {
let fix = fixture().await;
let doc = signed(
&fix.super_admin,
BACKUP_EXPORT_TYPE,
json!({ "password": PASSWORD }),
)
.await;
let out = dispatch_didcomm(&fix.vtc, &doc).await;
assert!(
out.status.is_success(),
"{}",
String::from_utf8_lossy(&out.body)
);
let envelope = &payload_of(&out)["envelope"];
assert_eq!(envelope["sourceDid"], TEST_VTC_DID);
assert!(
envelope["ciphertext"]
.as_str()
.is_some_and(|c| !c.is_empty())
);
assert_conforms::<backup_export::Response>(&out);
}
#[tokio::test]
async fn only_a_super_admin_may_export() {
let fix = fixture().await;
for signer in [&fix.scoped_admin, &fix.member] {
let doc = signed(signer, BACKUP_EXPORT_TYPE, json!({ "password": PASSWORD })).await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"{}",
String::from_utf8_lossy(&out.body)
);
}
}
#[tokio::test]
async fn vti_ops_020_an_unsigned_export_is_refused() {
let fix = fixture().await;
let doc = unsigned(
&fix.super_admin,
BACKUP_EXPORT_TYPE,
json!({ "password": PASSWORD }),
);
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(error_code(&out).as_deref(), Some("proofRequired"));
}
#[tokio::test]
async fn a_short_password_is_the_declared_refusal() {
let fix = fixture().await;
let short = "x".repeat(vta_sdk::protocols::backup_management::MIN_BACKUP_PASSWORD_LEN - 1);
let doc = signed(
&fix.super_admin,
BACKUP_EXPORT_TYPE,
json!({ "password": short }),
)
.await;
let out = dispatch_didcomm(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some(
trust_tasks_rs::specs::vtc::backup::export::v0_1::error_codes::PASSWORD_TOO_SHORT
.code
),
"{}",
String::from_utf8_lossy(&out.body)
);
}
#[tokio::test]
async fn vti_ops_025_a_redelivered_export_answers_with_the_same_envelope() {
let fix = fixture().await;
let doc = signed(
&fix.super_admin,
BACKUP_EXPORT_TYPE,
json!({ "password": PASSWORD }),
)
.await;
let first = dispatch_didcomm(&fix.vtc, &doc).await;
let second = dispatch_didcomm(&fix.vtc, &doc).await;
assert!(first.status.is_success() && second.status.is_success());
assert_eq!(
payload_of(&first)["envelope"],
payload_of(&second)["envelope"],
"a redelivery must be answered with the recorded export"
);
}
#[tokio::test]
async fn an_export_over_rest_is_refused() {
let fix = fixture().await;
let doc = signed(
&fix.super_admin,
BACKUP_EXPORT_TYPE,
json!({ "password": PASSWORD }),
)
.await;
let out = dispatch(&fix.vtc, &doc).await;
assert_eq!(
error_code(&out).as_deref(),
Some("permissionDenied"),
"{}",
String::from_utf8_lossy(&out.body)
);
assert!(String::from_utf8_lossy(&out.body).contains("DIDComm or TSP"));
}
#[tokio::test]
async fn an_export_with_no_audit_trail_is_refused() {
let vtc = TestVtc::builder()
.vtc_did(TEST_VTC_DID)
.with_audit(false)
.with_signers(true)
.build()
.await;
let store = {
let mut config = vtc.state.config.write().await;
config.secrets.backend = Some(crate::config::SecretBackend::Plaintext);
crate::keys::seed_store::create_secret_store(&config).expect("plaintext store")
};
store.set(b"signing-bundle").await.expect("seed the store");
let admin = Party::new();
seed_acl(&vtc, &admin.did, VtcRole::Admin, vec![]).await;
let doc = signed(&admin, BACKUP_EXPORT_TYPE, json!({ "password": PASSWORD })).await;
let out = dispatch_didcomm(&vtc, &doc).await;
assert_eq!(error_code(&out).as_deref(), Some("internalError"));
assert!(payload_of(&out).get("envelope").is_none());
}
}
#[cfg(test)]
mod vetter_grant_console_key_tests {
use super::members_admin_tests::{dispatch, error_code, signed};
use super::*;
use crate::acl::console_key::{ConsoleKeyDelegation, DelegationScope, store_delegation};
use crate::acl::{AdminAuthority, VtcAclEntry, VtcRole, store_acl_entry};
use crate::members::{Member, store_member};
use crate::test_support::TestVtc;
use serde_json::json;
use vti_rooms_dtg::test_support::Party;
const MEMBER: &str = "did:key:zVetterGrantConsoleKeyMember";
async fn seed(vtc: &TestVtc, did: &str, role: VtcRole, admin: AdminAuthority) {
store_acl_entry(
&vtc.state.acl_ks,
&VtcAclEntry {
did: did.into(),
role,
label: None,
admin,
delegated_by: None,
created_at: 0,
created_by: "did:key:vtc-install".into(),
updated_at: None,
updated_by: None,
expires_at: None,
resource_grants: Vec::new(),
label_set_by_subject: false,
suspension: None,
},
)
.await
.expect("seed ACL row");
}
async fn delegate(vtc: &TestVtc, console: &Party, admin_did: &str) {
store_delegation(
&vtc.state.console_keys_ks,
&ConsoleKeyDelegation {
console_did: console.did.clone(),
admin_did: admin_did.to_string(),
scope: DelegationScope::Console,
label: None,
created_at: chrono::Utc::now(),
expires_at: chrono::Utc::now() + chrono::Duration::days(1),
last_used_at: None,
revoked_at: None,
revoked_by: None,
},
)
.await
.expect("store delegation");
}
async fn community() -> (TestVtc, Party) {
let vtc = TestVtc::builder()
.with_audit(true)
.with_signers(true)
.build()
.await;
let admin = Party::new();
seed(
&vtc,
&admin.did,
VtcRole::Admin,
AdminAuthority::community_admin(),
)
.await;
seed(&vtc, MEMBER, VtcRole::Member, AdminAuthority::none()).await;
store_member(&vtc.state.members_ks, &Member::fresh(MEMBER))
.await
.expect("store member");
for purpose in [
affinidi_status_list::StatusPurpose::Revocation,
affinidi_status_list::StatusPurpose::Suspension,
] {
crate::status_list::ensure_initial(
&vtc.state.status_lists_ks,
purpose,
format!("https://vtc.example.com/v1/status-lists/{purpose}"),
)
.await
.expect("provision status list");
}
(vtc, admin)
}
fn grant() -> Value {
json!({ "memberDid": MEMBER })
}
#[tokio::test]
async fn vti_acl_030_a_console_key_grants_a_vetter_as_its_community_admin() {
let (vtc, admin) = community().await;
let console = Party::new();
delegate(&vtc, &console, &admin.did).await;
let doc = signed(&console, vetting_wire::VETTING_VETTER_GRANT_TYPE, grant()).await;
let out = dispatch(&vtc, &doc).await;
assert_eq!(error_code(&out), None, "{out:?}");
let rows = crate::vetting::vetters::grant_rows(&vtc.state)
.await
.expect("grant rows");
assert!(
rows.iter().any(|r| r.member_did == MEMBER && !r.revoked),
"the member is a vetter: {rows:?}"
);
}
#[tokio::test]
async fn vti_acl_030_a_console_key_with_no_delegation_grants_nothing() {
let (vtc, _admin) = community().await;
let console = Party::new();
let doc = signed(&console, vetting_wire::VETTING_VETTER_GRANT_TYPE, grant()).await;
let out = dispatch(&vtc, &doc).await;
assert!(error_code(&out).is_some(), "refused: {out:?}");
let rows = crate::vetting::vetters::grant_rows(&vtc.state)
.await
.expect("grant rows");
assert!(rows.is_empty(), "nothing granted: {rows:?}");
}
#[tokio::test]
async fn vti_acl_030_a_console_key_of_an_admin_without_vetting_manage_grants_nothing() {
let (vtc, _admin) = community().await;
let moderator = Party::new();
seed(
&vtc,
&moderator.did,
VtcRole::Admin,
AdminAuthority::for_role(crate::acl::AdminRole::Moderator),
)
.await;
let console = Party::new();
delegate(&vtc, &console, &moderator.did).await;
let doc = signed(&console, vetting_wire::VETTING_VETTER_GRANT_TYPE, grant()).await;
let out = dispatch(&vtc, &doc).await;
assert!(error_code(&out).is_some(), "refused: {out:?}");
}
}