use chrono::{DateTime, Utc};
use clap::Subcommand;
use serde_json::Value;
use vta_cli_common::render::{DIM, GREEN, RESET, YELLOW, bin_name, is_json_output, print_json};
use vta_sdk::session::TransportChoice;
use vtc_client::acl::{AclGrant, AclListFilter, AclUpdate};
use vtc_client::{HolderKey, VtcError};
use crate::auth;
use crate::vtc::{self as vtc_target, Connected, VtcTarget};
type CliResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
#[derive(Subcommand)]
pub enum AccessCommands {
List {
#[arg(long = "admin-role")]
admin_role: Option<String>,
#[arg(long)]
capability: Option<String>,
#[arg(long)]
resource: Option<String>,
#[arg(long, requires = "resource")]
direction: Option<String>,
#[arg(long)]
subject_prefix: Option<String>,
},
Show {
subject: String,
},
Grant {
subject: String,
#[arg(long = "admin-role")]
admin_role: String,
#[arg(long = "capability")]
capabilities: Vec<String>,
#[arg(long)]
approve: bool,
#[arg(long)]
approve_only: bool,
#[arg(long)]
community_role: Option<String>,
#[arg(long)]
label: Option<String>,
#[arg(long)]
expires: Option<String>,
#[arg(long)]
reason: Option<String>,
},
Update {
subject: String,
#[arg(long = "capability", conflicts_with = "full_ceiling")]
capabilities: Vec<String>,
#[arg(long)]
full_ceiling: bool,
#[arg(long)]
approve: Option<bool>,
#[arg(long, conflicts_with = "clear_label")]
label: Option<String>,
#[arg(long)]
clear_label: bool,
#[arg(long, conflicts_with = "permanent")]
expires: Option<String>,
#[arg(long)]
permanent: bool,
#[arg(long)]
reason: Option<String>,
},
ChangeRole {
subject: String,
#[arg(long = "from")]
from_role: String,
#[arg(long = "to")]
to_role: String,
#[arg(long)]
reason: Option<String>,
},
Revoke {
subject: String,
#[arg(long)]
reason: Option<String>,
#[arg(long)]
now: bool,
#[arg(long, requires = "now")]
action: Option<String>,
#[arg(long, requires = "now")]
confirm: Option<String>,
},
}
fn confirm_now(subject: &str, action: Option<&str>, given: Option<String>) -> CliResult<String> {
use std::io::IsTerminal as _;
let typed = match given {
Some(c) => c,
None if std::io::stderr().is_terminal() => {
eprintln!(
"This removes {subject} {}, without the cooling-off — single-administrator mode.",
if action.is_some() {
"now, landing the open cooling-off"
} else {
"now"
}
);
dialoguer::Input::<String>::new()
.with_prompt(match action {
Some(_) => "Type the subject's DID, or the action id, to confirm",
None => "Type the subject's DID to confirm",
})
.interact_text()?
}
None => {
return Err(
"removing now needs a typed confirmation: pass `--confirm <the subject's DID>`"
.into(),
);
}
};
let typed = typed.trim().to_string();
if typed != subject && action.is_none_or(|a| typed != a) {
return Err("the confirmation does not match — nothing was removed".into());
}
Ok(typed)
}
pub async fn run(
command: AccessCommands,
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
}
pub(crate) fn signing_key(keyring_key: &str) -> CliResult<HolderKey> {
let session = auth::loaded_session(keyring_key).ok_or_else(|| {
format!(
"no stored identity for this community profile. Run `{} setup` first.",
bin_name()
)
})?;
HolderKey::from_did_key(&session.client_did, &session.private_key_multibase)
.map_err(|e| format!("this profile's key cannot sign: {e}").into())
}
async fn run_command(command: AccessCommands, vtc: &Connected, keyring_key: &str) -> CliResult {
let key = signing_key(keyring_key)?;
let fail = |e: VtcError| access_error(vtc, e);
match command {
AccessCommands::List {
admin_role,
capability,
resource,
direction,
subject_prefix,
} => {
let direction = match (&resource, direction) {
(Some(_), None) => Some("actingIn".to_string()),
(_, d) => d,
};
let filter = AclListFilter {
role: admin_role,
capability,
resource,
direction,
subject_prefix,
..Default::default()
};
let entries = vtc.client.acl_list_all(&filter, &key).await.map_err(fail)?;
let values: Vec<Value> = entries
.iter()
.map(serde_json::to_value)
.collect::<Result<_, _>>()?;
if is_json_output() {
print_json(&values)?;
} else if values.is_empty() {
println!("{DIM}no entries{RESET}");
} else {
for v in &values {
print_entry(v);
}
}
}
AccessCommands::Show { subject } => {
let shown = vtc.client.acl_show(&subject, &key).await.map_err(fail)?;
let shown = serde_json::to_value(&shown)?;
if shown["entry"].is_null() && !is_json_output() {
println!("{DIM}{subject} holds no entry{RESET}");
} else {
report(&shown, "entry")?;
}
}
AccessCommands::Grant {
subject,
admin_role,
capabilities,
approve,
approve_only,
community_role,
label,
expires,
reason,
} => {
let grant = AclGrant {
subject,
admin_role,
capabilities: if approve_only {
Some(Vec::new())
} else {
Some(capabilities).filter(|c| !c.is_empty())
},
approve: approve || approve_only,
act: !approve_only,
community_role,
label,
expires_at: expires.as_deref().map(expiry_from_now).transpose()?,
reason,
};
let granted = vtc.client.acl_grant(&grant, &key).await.map_err(fail)?;
report(&serde_json::to_value(&granted)?, "granted")?;
}
AccessCommands::Update {
subject,
capabilities,
full_ceiling,
approve,
label,
clear_label,
expires,
permanent,
reason,
} => {
let update = AclUpdate {
subject,
label: if clear_label {
Some(None)
} else {
label.map(Some)
},
capabilities: if full_ceiling {
Some(None)
} else {
Some(capabilities).filter(|c| !c.is_empty()).map(Some)
},
approve,
expires_at: if permanent {
Some(None)
} else {
expires
.as_deref()
.map(expiry_from_now)
.transpose()?
.map(Some)
},
reason,
};
let updated = vtc.client.acl_update(&update, &key).await.map_err(fail)?;
report(&serde_json::to_value(&updated)?, "updated")?;
}
AccessCommands::ChangeRole {
subject,
from_role,
to_role,
reason,
} => {
let changed = vtc
.client
.acl_change_role(&subject, &from_role, &to_role, reason.as_deref(), &key)
.await
.map_err(fail)?;
report(&serde_json::to_value(&changed)?, "role changed")?;
}
AccessCommands::Revoke {
subject,
reason,
now,
action,
confirm,
} => {
let revoked = if now {
let typed = confirm_now(&subject, action.as_deref(), confirm)?;
vtc.client
.acl_revoke_now(&subject, &typed, action.as_deref(), reason.as_deref(), &key)
.await
} else {
vtc.client
.acl_revoke(&subject, reason.as_deref(), &key)
.await
}
.map_err(fail)?;
let v = serde_json::to_value(&revoked)?;
if is_json_output() {
print_json(&v)?;
} else {
println!("{GREEN}revoked{RESET} {subject} — the entry is removed");
}
}
}
Ok(())
}
fn expiry_from_now(s: &str) -> CliResult<DateTime<Utc>> {
let secs = vta_cli_common::duration::parse_duration_secs(s)?;
let secs = i64::try_from(secs).map_err(|_| format!("--expires {s} is too far away"))?;
Ok(Utc::now() + chrono::TimeDelta::seconds(secs))
}
fn report(reply: &Value, verb: &str) -> CliResult {
if is_json_output() {
print_json(reply)?;
} else {
println!("{GREEN}{verb}{RESET}");
print_entry(&reply["entry"]);
}
Ok(())
}
fn print_entry(e: &Value) {
let s = |k: &str| e.get(k).and_then(Value::as_str);
let role = s("role").unwrap_or("?");
let acts = e["act"]["scope"] == "all";
let holds = match e["capabilities"]["scope"].as_str() {
_ if role == "member" => "nothing".to_string(),
_ if !acts => "acts nowhere (approves only)".to_string(),
Some("ceiling") if role == "community-admin" => "everything".to_string(),
Some("ceiling") => format!("the {role} ceiling"),
Some("none") => "nothing".to_string(),
Some("listed") => e["capabilities"]["grants"]
.as_array()
.map(|g| {
g.iter()
.map(|g| match g["resource"].as_str() {
Some(r) => format!("{}@{r}", g["capability"].as_str().unwrap_or("?")),
None => g["capability"].as_str().unwrap_or("?").to_string(),
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default(),
_ => "?".to_string(),
};
let community = e["ext"]["org.openvtc"]["communityRole"]
.as_str()
.map(|c| format!(" {DIM}community role {c}{RESET}"))
.unwrap_or_default();
let scopes = holds;
let suspended = e["ext"]["org.openvtc"]["suspended"]["landsAt"]
.as_str()
.map(|t| format!(" {YELLOW}suspended — removal lands {t}{RESET}"))
.unwrap_or_default();
println!(
" {} {role} {scopes}{community}{}{}{suspended}",
s("subject").unwrap_or("?"),
s("label").map(|l| format!(" \"{l}\"")).unwrap_or_default(),
s("expiresAt")
.map(|t| format!(" {DIM}expires {t}{RESET}"))
.unwrap_or_default(),
);
}
fn access_error(vtc: &Connected, err: VtcError) -> Box<dyn std::error::Error> {
if let VtcError::Parked { action_id, message } = &err {
if message.contains("cooling-off") {
return format!(
"{message}\n Nothing more to send: it lands by itself. Follow it with `{bin} \
actions show {action_id}`, or withdraw it with `{bin} actions cancel \
{action_id}`. In single-administrator mode, `{bin} access revoke <subject> \
--now --action {action_id}` lands it now.",
bin = bin_name()
)
.into();
}
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 text.contains("stepUpRequest") || text.contains("passkey gesture") {
return format!(
"the VTC needs a passkey gesture from {} before it makes this change. 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::*;
#[test]
fn remove_now_needs_the_subject_or_the_action_typed_back() {
let did = "did:key:zSubject";
assert_eq!(
confirm_now(did, None, Some(format!(" {did} "))).unwrap(),
did
);
assert!(confirm_now(did, None, Some("did:key:zOther".into())).is_err());
assert!(confirm_now(did, None, Some("act-1".into())).is_err());
assert_eq!(
confirm_now(did, Some("act-1"), Some("act-1".into())).unwrap(),
"act-1"
);
assert_eq!(
confirm_now(did, Some("act-1"), Some(did.into())).unwrap(),
did
);
}
}