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 {
AuthorizeKey {
signing_key_did: String,
#[arg(long)]
label: Option<String>,
#[arg(long)]
expires: Option<String>,
#[arg(long)]
replaces: Option<String>,
#[arg(long, default_value = "1h")]
valid_for: String,
#[arg(long)]
out: Option<std::path::PathBuf>,
},
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 {
if let AccessCommands::AuthorizeKey {
signing_key_did,
label,
expires,
replaces,
valid_for,
out,
} = command
{
let key = signing_key(keyring_key)?;
let doc = authorize_key_document(
&key,
&target.did,
&signing_key_did,
label.as_deref(),
expires.as_deref(),
replaces.as_deref(),
&valid_for,
chrono::Utc::now(),
)
.await?;
let text = serde_json::to_string_pretty(&doc)?;
match out {
Some(path) => {
write_private(&path, &text)?;
eprintln!(
"Authorization for {signing_key_did} to act as {} written to {}.\nHand it to \
the key's holder (vtc-mcp: vtc_enrol_signing_key with this file). It is \
single-use and only that key can use it.",
key.holder_did(),
path.display()
);
}
None => println!("{text}"),
}
return Ok(());
}
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
}
const AUTHORIZE_TYPE: &str = "https://trusttasks.org/spec/auth/signing-key/authorize/0.1";
fn parse_duration(s: &str) -> CliResult<chrono::TimeDelta> {
let s = s.trim();
let (n, unit) = s.split_at(s.len().saturating_sub(1));
let n: i64 = n
.parse()
.map_err(|_| format!("`{s}` is not a duration: N followed by s, m, h, d or w"))?;
Ok(match unit {
"s" => chrono::TimeDelta::seconds(n),
"m" => chrono::TimeDelta::minutes(n),
"h" => chrono::TimeDelta::hours(n),
"d" => chrono::TimeDelta::days(n),
"w" => chrono::TimeDelta::weeks(n),
_ => return Err(format!("`{s}` is not a duration: N followed by s, m, h, d or w").into()),
})
}
#[allow(clippy::too_many_arguments)]
async fn authorize_key_document(
key: &HolderKey,
vtc_did: &str,
signing_key_did: &str,
label: Option<&str>,
expires: Option<&str>,
replaces: Option<&str>,
valid_for: &str,
now: chrono::DateTime<chrono::Utc>,
) -> CliResult<serde_json::Value> {
if !signing_key_did.starts_with("did:key:") {
return Err(format!(
"`{signing_key_did}` is not a did:key; a signing key is named by its own did:key"
)
.into());
}
if signing_key_did == key.holder_did() {
return Err("a key cannot be authorized to act as itself".into());
}
let mut terms = serde_json::json!({
"identityDid": key.holder_did(),
"signingKeyDid": signing_key_did,
"scope": "console",
});
if let Some(label) = label {
terms["deviceLabel"] = serde_json::json!(label);
}
if let Some(expires) = expires {
let at = now + parse_duration(expires)?;
terms["expiresAt"] =
serde_json::json!(at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
}
if let Some(replaces) = replaces {
terms["replaces"] = serde_json::json!(replaces);
}
let mut doc =
vta_sdk::trust_task_sign::build_unsigned(AUTHORIZE_TYPE, terms, key.holder_did(), vtc_did)
.map_err(|e| format!("could not build the authorization: {e}"))?;
doc.issued_at = Some(now);
doc.expires_at = Some(now + parse_duration(valid_for)?);
vta_sdk::trust_task_sign::sign_in_place_with(&mut doc, key)
.await
.map_err(|e| format!("could not sign the authorization: {e}"))?;
Ok(serde_json::to_value(&doc)?)
}
fn write_private(path: &std::path::Path, text: &str) -> CliResult {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.create(true).truncate(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(text.as_bytes())?;
Ok(())
}
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::AuthorizeKey { .. } => {}
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
.members_role_set(&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::*;
fn profile_key(seed: u8) -> HolderKey {
let signing = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]);
let did = format!(
"did:key:{}",
vta_sdk::did_key::ed25519_multibase_pubkey(&signing.verifying_key().to_bytes())
);
let private = multibase::encode(multibase::Base::Base58Btc, signing.to_bytes());
HolderKey::from_did_key(&did, private).unwrap()
}
#[tokio::test]
async fn an_authorization_is_the_identitys_signed_terms() {
let identity = profile_key(1);
let bridge = profile_key(2);
let now = chrono::DateTime::parse_from_rfc3339("2026-11-07T00:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let doc = authorize_key_document(
&identity,
"did:web:vtc.example.com",
bridge.holder_did(),
Some("vtc-mcp laptop"),
Some("30d"),
None,
"1h",
now,
)
.await
.unwrap();
assert_eq!(doc["type"], AUTHORIZE_TYPE);
assert_eq!(doc["issuer"], identity.holder_did());
assert_eq!(doc["recipient"], "did:web:vtc.example.com");
assert_eq!(doc["expiresAt"], "2026-11-07T01:00:00Z");
assert!(doc["proof"].is_object());
let terms = &doc["payload"];
assert_eq!(terms["identityDid"], identity.holder_did());
assert_eq!(terms["signingKeyDid"], bridge.holder_did());
assert_eq!(terms["scope"], "console");
assert_eq!(terms["deviceLabel"], "vtc-mcp laptop");
assert_eq!(terms["expiresAt"], "2026-12-07T00:00:00Z");
assert!(terms.get("replaces").is_none());
vti_specs::schema_index::schema_for(AUTHORIZE_TYPE)
.map(|s| trust_tasks_rs::validate::against_schema(s, terms).unwrap())
.expect("authorize/0.1 is pinned");
}
#[tokio::test]
async fn an_authorization_refuses_what_could_never_enrol() {
let identity = profile_key(1);
let now = chrono::Utc::now();
for (did, why) in [
("did:webvh:abc:example.com:x", "not a did:key"),
(identity.holder_did(), "act as itself"),
] {
let err =
authorize_key_document(&identity, "did:web:v", did, None, None, None, "1h", now)
.await
.unwrap_err()
.to_string();
assert!(err.contains(why), "{err}");
}
assert!(parse_duration("30x").is_err());
assert_eq!(parse_duration("2w").unwrap(), chrono::TimeDelta::weeks(2));
}
#[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
);
}
}