use clap::Subcommand;
use vta_cli_common::render::{DIM, GREEN, RESET, bin_name, is_json_output, print_json};
use vta_sdk::session::TransportChoice;
use vtc_client::VtcError;
use vtc_client::members::{AdmitRequest, admit, make_membership};
use crate::vtc::{self as vtc_target, Connected, VtcTarget};
type CliResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
#[derive(Subcommand)]
pub enum MembersCommands {
Admit {
subject: String,
#[arg(long, default_value = "member")]
role: String,
#[arg(long)]
label: Option<String>,
#[arg(long)]
reason: Option<String>,
},
MakeMembership {
subject: String,
#[arg(long)]
reason: Option<String>,
},
}
pub async fn run(
command: MembersCommands,
keyring_key: &str,
target: &VtcTarget,
transport: TransportChoice,
) -> CliResult {
let vtc = vtc_target::connect_for_tasks(keyring_key, target, transport).await?;
let outcome = run_command(command, &vtc, keyring_key).await;
vtc.client.shutdown().await;
outcome
}
async fn run_command(command: MembersCommands, vtc: &Connected, keyring_key: &str) -> CliResult {
let key = crate::access::signing_key(keyring_key)?;
match command {
MembersCommands::Admit {
subject,
role,
label,
reason,
} => {
let req = AdmitRequest {
subject: subject.clone(),
role: role.clone(),
label,
reason,
};
let admitted = vtc
.client
.members_admit(&req, &key)
.await
.map_err(|e| admission_error(vtc, e, &subject, Some(&role)))?;
report(
&admitted,
&admitted.subject,
&admitted.role,
admitted.approval,
)
}
MembersCommands::MakeMembership { subject, reason } => {
let admitted = vtc
.client
.members_make_membership(&subject, reason.as_deref(), &key)
.await
.map_err(|e| admission_error(vtc, e, &subject, None))?;
report(
&admitted,
&admitted.subject,
&admitted.role,
admitted.approval.into_admit(),
)
}
}
}
trait IntoAdmitApproval {
fn into_admit(self) -> admit::Approval;
}
impl IntoAdmitApproval for make_membership::Approval {
fn into_admit(self) -> admit::Approval {
match self {
Self::PolicyAllowed => admit::Approval::PolicyAllowed,
Self::Approved => admit::Approval::Approved,
Self::Waived => admit::Approval::Waived,
_ => admit::Approval::Approved,
}
}
}
fn report(
reply: &impl serde::Serialize,
subject: &str,
role: &str,
approval: admit::Approval,
) -> CliResult {
if is_json_output() {
print_json(reply)?;
return Ok(());
}
let how = match approval {
admit::Approval::Waived => " (single-administrator mode waived the approval)",
admit::Approval::Approved => " after approval",
_ => "",
};
println!("{GREEN}admitted{RESET} {subject} as {role}{how}");
println!(" {DIM}the membership credential and role credential were issued{RESET}");
Ok(())
}
fn declared_code(err: &VtcError) -> Option<&'static str> {
let text = match err {
VtcError::Http { body, .. } => body.as_str(),
VtcError::Refused { document } => document.as_str(),
VtcError::NotFound { code, .. } => code.as_str(),
_ => return None,
};
admit::ERROR_CODES
.iter()
.chain(make_membership::ERROR_CODES)
.map(|d| d.code)
.find(|c| text.contains(&format!("\"{c}\"")) || text == *c)
}
fn suggested_fix(code: &str, subject: &str, role: Option<&str>) -> Option<String> {
let bin = bin_name();
let admit_cmd = format!("{bin} members admit {subject} --role member");
Some(match code {
c if c == admit::error_codes::HAS_ENTRY.code => format!(
"{subject} already holds an access-control entry. To make it a membership at the \
role it holds, run:\n {bin} members make-membership {subject}"
),
c if c == make_membership::error_codes::NO_ENTRY.code => format!(
"{subject} holds no access-control entry, so there is nothing to make a membership \
of. To admit them, run:\n {admit_cmd}"
),
c if c == make_membership::error_codes::APPLICATION_ENTRY.code => format!(
"{subject}'s entry is an application entry (git rights held by a non-member). To \
admit them, keeping those rights, run:\n {admit_cmd}"
),
c if c == make_membership::error_codes::REMOVED_MEMBER.code => format!(
"{subject} was a member and was removed. To re-admit them, remove the entry and \
admit them, which the admission policy decides:\n {bin} access revoke {subject}\n \
{admit_cmd}"
),
c if c == admit::error_codes::ROLE_NOT_PERMITTED.code && role == Some("admin") => format!(
"a direct admission never seats admin. Admit them as a member, then promote them \
(which takes its own gesture and another administrator's consent):\n \
{admit_cmd}\n {bin} access change-role {subject} --from member --to admin"
),
_ => return None,
})
}
fn admission_error(
vtc: &Connected,
err: VtcError,
subject: &str,
role: Option<&str>,
) -> Box<dyn std::error::Error> {
if let VtcError::Parked { action_id, message } = &err {
return format!(
"{message}\n Nothing more to send: it runs when the approvals land. Follow it with \
`{bin} actions show {action_id}`, or withdraw it with `{bin} actions cancel \
{action_id}`.",
bin = bin_name()
)
.into();
}
let text = err.to_string();
if let Some(fix) = declared_code(&err).and_then(|c| suggested_fix(c, subject, role)) {
return format!("{fix}\n({text})").into();
}
if text.contains("stepUpRequest") || text.contains("passkey gesture") {
return format!(
"the VTC needs a passkey gesture from {} before it admits {subject}. Approve it in \
the admin console, then run the same command again.\n({text})",
vtc.client_did
)
.into();
}
format!("the VTC refused {}: {text}", vtc.client_did).into()
}
#[cfg(test)]
mod tests {
use super::*;
fn refused(code: &str) -> VtcError {
VtcError::Refused {
document: serde_json::json!({
"payload": { "code": code, "message": "no" },
})
.to_string(),
}
}
#[test]
fn a_wrong_command_refusal_names_the_right_one() {
let s = "did:key:zSubject";
let code = declared_code(&refused(admit::error_codes::HAS_ENTRY.code)).unwrap();
assert!(
suggested_fix(code, s, Some("member"))
.unwrap()
.contains(&format!("members make-membership {s}"))
);
let code = declared_code(&refused(make_membership::error_codes::NO_ENTRY.code)).unwrap();
assert!(
suggested_fix(code, s, None)
.unwrap()
.contains(&format!("members admit {s} --role member"))
);
let code = declared_code(&refused(admit::error_codes::ROLE_NOT_PERMITTED.code)).unwrap();
let fix = suggested_fix(code, s, Some("admin")).unwrap();
assert!(fix.contains(&format!("access change-role {s} --from member --to admin")));
assert!(suggested_fix(code, s, Some("overlord")).is_none());
}
#[test]
fn codes_are_matched_whole() {
assert_eq!(
declared_code(&refused(make_membership::error_codes::ALREADY_MEMBER.code)),
Some(make_membership::error_codes::ALREADY_MEMBER.code)
);
assert_eq!(declared_code(&refused("alreadyMember")), None);
}
}