use chrono::{DateTime, Utc};
use clap::Subcommand;
use serde_json::Value;
use vta_cli_common::render::{DIM, GREEN, RESET, 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)]
role: Option<String>,
#[arg(long)]
scope: Option<String>,
#[arg(long, requires = "scope")]
direction: Option<String>,
#[arg(long)]
subject_prefix: Option<String>,
},
Show {
subject: String,
},
Grant {
subject: String,
#[arg(long)]
role: String,
#[arg(long, value_delimiter = ',')]
scopes: Vec<String>,
#[arg(long)]
label: Option<String>,
#[arg(long)]
expires: Option<String>,
#[arg(long)]
reason: Option<String>,
},
Update {
subject: String,
#[arg(long, value_delimiter = ',')]
scopes: Option<Vec<String>>,
#[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, value_delimiter = ',')]
scopes: Option<Vec<String>>,
#[arg(long)]
reason: Option<String>,
},
}
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
}
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 {
role,
scope,
direction,
subject_prefix,
} => {
let filter = AclListFilter {
role,
scope,
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)?;
report(&serde_json::to_value(&shown)?, "entry")?;
}
AccessCommands::Grant {
subject,
role,
scopes,
label,
expires,
reason,
} => {
let grant = AclGrant {
subject,
role,
scopes,
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,
scopes,
label,
clear_label,
expires,
permanent,
reason,
} => {
let update = AclUpdate {
subject,
label: if clear_label {
Some(None)
} else {
label.map(Some)
},
scopes,
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,
scopes,
reason,
} => {
let revoked = vtc
.client
.acl_revoke(&subject, scopes.as_deref(), reason.as_deref(), &key)
.await
.map_err(fail)?;
let v = serde_json::to_value(&revoked)?;
if is_json_output() {
print_json(&v)?;
} else if v["entry"].is_null() {
println!("{GREEN}revoked{RESET} {subject} — the entry is removed");
} else {
println!("{GREEN}scopes revoked{RESET}; {subject} now holds:");
print_entry(&v["entry"]);
}
}
}
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 scopes = e
.get("scopes")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let role = s("role").unwrap_or("?");
let scopes = match (scopes.is_empty(), role) {
(true, "admin") => "(community-wide)".to_string(),
(true, _) => "(none — acts nowhere)".to_string(),
(false, _) => scopes,
};
println!(
" {} {role} {scopes}{}{}",
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> {
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();
}
if text.contains("consentRequests") {
return format!(
"another unrestricted administrator has to approve this first; they have been sent \
the request (`{} consent approve` answers it). Once one approves, run the same \
command again.\n({text})",
bin_name()
)
.into();
}
format!("the VTC refused {}: {text}", vtc.client_did).into()
}