use ratatui::{
layout::Constraint,
style::{Color, Modifier, Style},
widgets::{Block, Cell, Row, Table},
};
use vta_sdk::acl::{ApproveScope, ContextDirection};
use vta_sdk::client::ChangeAclRoleRequest;
use vta_sdk::prelude::*;
use vti_common::acl::{Role, act_scope_for};
use crate::display::{
NAME_HEADER, NameBook, NameSource, book_from_acl, did_cell, full_display_pairs, name_cell,
named_did_cell, resolve_agent_names_into,
};
use crate::render::{is_full_display, print_full_entry_owned, print_full_list_title, print_widget};
pub fn format_contexts(role: &str, contexts: &[String]) -> String {
let role = Role::parse(role).unwrap_or(Role::Monitor);
act_scope_for(&role, contexts).to_string()
}
pub fn format_role(role: &str, contexts: &[String]) -> String {
if role == "admin" && contexts.is_empty() {
"super admin".to_string()
} else {
role.to_string()
}
}
pub fn format_approve_scope(approve_all: bool, approve_contexts: &[String]) -> Option<String> {
if approve_all {
Some("all contexts".to_string())
} else if !approve_contexts.is_empty() {
Some(format!("contexts [{}]", approve_contexts.join(", ")))
} else {
None
}
}
pub fn format_allowed_keys(allowed_keys: Option<&[String]>) -> Option<String> {
match allowed_keys {
None => None,
Some([]) => Some("(none — may invoke the signing oracle on no keys)".to_string()),
Some(keys) => Some(format!("keys [{}]", keys.join(", "))),
}
}
pub fn allowed_keys_from_flags(
allowed_keys: Option<Vec<String>>,
allowed_keys_unrestricted: bool,
) -> Option<Option<Vec<String>>> {
if allowed_keys_unrestricted {
Some(None)
} else {
allowed_keys.map(Some)
}
}
pub fn validate_role(role: &str) -> Result<(), Box<dyn std::error::Error>> {
match role {
"admin" | "initiator" | "application" | "reader" => Ok(()),
_ => Err(format!(
"invalid role '{role}', expected: admin, initiator, application, or reader"
)
.into()),
}
}
pub fn parse_direction(
direction: Option<&str>,
) -> Result<ContextDirection, Box<dyn std::error::Error>> {
match direction {
None => Ok(ContextDirection::default()),
Some(d) => Ok(d.parse::<ContextDirection>()?),
}
}
pub fn describe_filter(context: &str, direction: ContextDirection) -> String {
match direction {
ContextDirection::ActingIn => format!("able to act in {context}"),
ContextDirection::Subtree => format!("granted at or beneath {context}"),
ContextDirection::Any => format!("with authority touching {context}"),
}
}
pub async fn cmd_acl_list(
client: &VtaClient,
context: Option<&str>,
direction: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let direction = parse_direction(direction)?;
if context.is_none() && direction != ContextDirection::default() {
return Err(format!(
"--direction {direction} says how to read --context, and no --context was given.\n\
Try: pnm acl list --context <id> --direction {direction}"
)
.into());
}
let resp = client.list_acl_in_direction(context, direction).await?;
if crate::render::is_json_output() {
crate::render::print_json(&resp.entries)?;
return Ok(());
}
if resp.entries.is_empty() {
match context {
Some(ctx) => println!("No ACL entries found {}.", describe_filter(ctx, direction)),
None => println!("No ACL entries found."),
}
return Ok(());
}
let mut book = NameBook::new();
book_from_acl(&mut book, &resp.entries);
resolve_agent_names_into(
&mut book,
resp.entries
.iter()
.flat_map(|e| [e.did.as_str(), e.created_by.as_str()]),
)
.await;
let heading = match context {
Some(ctx) => format!("ACL Entries — {}", describe_filter(ctx, direction)),
None => "ACL Entries".to_string(),
};
if is_full_display() {
print_full_list_title(&heading, resp.entries.len());
for entry in &resp.entries {
let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
let role = format_role(&entry.role, &entry.allowed_contexts);
let approve =
format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts());
let mut fields = full_display_pairs(&book, &entry.did);
fields.push(("Role", role));
if let Some(label) = entry.label.as_deref()
&& book.name_of(&entry.did).as_deref() != Some(label)
{
fields.push(("Label", label.to_string()));
}
fields.push(("Contexts", contexts));
if let Some(k) = format_allowed_keys(entry.allowed_keys.as_deref()) {
fields.push(("Allowed Keys", k));
}
if let Some(a) = approve {
fields.push(("Approve", a));
}
fields.push(("Created By", book.render_inline(&entry.created_by)));
print_full_entry_owned(&fields);
}
return Ok(());
}
let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
let header_style = Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD);
let mut header_cells = vec!["DID", "Role", "Contexts", "Created By"];
if show_names {
header_cells.insert(0, NAME_HEADER);
}
let header = Row::new(header_cells).style(header_style).bottom_margin(1);
let rows: Vec<Row> = resp
.entries
.iter()
.map(|entry| {
let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
let mut cells = vec![
did_cell(&entry.did),
Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
Cell::from(contexts),
named_did_cell(&book, &entry.created_by),
];
if show_names {
cells.insert(0, name_cell(&book, &entry.did));
}
Row::new(cells)
})
.collect();
let title = format!(" {heading} ({}) ", resp.entries.len());
let mut constraints = vec![
Constraint::Min(34), Constraint::Length(12), Constraint::Length(24), Constraint::Min(30), ];
if show_names {
constraints.insert(0, Constraint::Min(16));
}
let table = Table::new(rows, constraints)
.header(header)
.column_spacing(2)
.block(
Block::bordered()
.title(title)
.border_style(Style::default().fg(Color::DarkGray)),
);
let height = resp.entries.len() as u16 + 4;
print_widget(table, height);
Ok(())
}
pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
let entry = client.get_acl(did).await?;
let mut book = NameBook::new();
book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
resolve_agent_names_into(&mut book, [entry.did.as_str()]).await;
match book.name_of(&entry.did) {
Some(name) => {
println!("Name: {name}");
println!("DID: {}", entry.did);
}
None => println!("DID: {}", entry.did),
}
println!(
"Role: {}",
format_role(&entry.role, &entry.allowed_contexts)
);
if let Some(label) = entry.label.as_deref()
&& book.name_of(&entry.did).as_deref() != Some(label)
{
println!("Label: {label}");
}
println!(
"Contexts: {}",
format_contexts(&entry.role, &entry.allowed_contexts)
);
if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
println!("Allowed keys: {keys}");
}
if let Some(scope) =
format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
{
println!("Approve: {scope}");
}
println!("Created At: {}", entry.created_at);
println!("Created By: {}", entry.created_by);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_acl_create(
client: &VtaClient,
did: String,
role: String,
label: Option<String>,
contexts: Vec<String>,
expires_at: Option<u64>,
step_up_approver: Option<String>,
step_up_require: Option<String>,
approve_all: bool,
approve_contexts: Vec<String>,
allowed_keys: Option<Vec<String>>,
) -> Result<(), Box<dyn std::error::Error>> {
validate_role(&role)?;
let mut req = CreateAclRequest::new(did, role).contexts(contexts);
if let Some(keys) = allowed_keys {
req = req.allowed_keys(keys);
}
if let Some(l) = label {
req = req.label(l);
}
if let Some(secs) = expires_at {
req = req.expires_at(secs);
}
if let Some(ref approver) = step_up_approver {
req = req.step_up_approver(approver.clone());
}
if let Some(ref require) = step_up_require {
req = req.step_up_require(require.clone());
}
if approve_all {
req = req.approve_all();
} else if !approve_contexts.is_empty() {
req = req.approve_contexts(approve_contexts);
}
let entry = client.create_acl(req).await?;
println!("ACL entry created:");
println!(" DID: {}", entry.did);
println!(
" Role: {}",
format_role(&entry.role, &entry.allowed_contexts)
);
if let Some(label) = &entry.label {
println!(" Label: {label}");
}
println!(
" Contexts: {}",
format_contexts(&entry.role, &entry.allowed_contexts)
);
if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
println!(" Allowed keys: {keys}");
}
if let Some(scope) =
format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
{
println!(" Approve: {scope}");
}
if let Some(approver) = &step_up_approver {
println!(" Step-up approver: {approver}");
}
if let Some(require) = &step_up_require {
println!(" Step-up require: {require}");
}
match entry.expires_at {
Some(secs) => println!(
" Expires at: {} ({})",
crate::duration::format_local_time(secs),
crate::duration::format_remaining(secs),
),
None => println!(" Expires at: (permanent)"),
}
Ok(())
}
pub fn approve_scope_from_flags(
approve_all: bool,
approve_contexts: Option<Vec<String>>,
approve_none: bool,
) -> Option<ApproveScope> {
if approve_none {
Some(ApproveScope::None)
} else if approve_all {
Some(ApproveScope::All)
} else {
approve_contexts.map(ApproveScope::Contexts)
}
}
pub async fn cmd_acl_change_role(
client: &VtaClient,
did: &str,
from_role: &str,
to_role: &str,
reason: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
validate_role(from_role)?;
validate_role(to_role)?;
let entry = client
.change_acl_role(
did,
ChangeAclRoleRequest {
from_role: from_role.to_string(),
to_role: to_role.to_string(),
reason,
},
)
.await?;
println!("ACL role changed:");
println!(" DID: {}", entry.did);
println!(
" Role: {} \u{2192} {}",
from_role,
format_role(&entry.role, &entry.allowed_contexts)
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_acl_update(
client: &VtaClient,
did: &str,
role: Option<String>,
label: Option<String>,
contexts: Option<Vec<String>>,
step_up_approver: Option<String>,
step_up_require: Option<String>,
approve_scope: Option<ApproveScope>,
allowed_keys: Option<Option<Vec<String>>>,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(ref r) = role {
validate_role(r)?;
let current = client
.get_acl(did)
.await
.ok()
.map(|e| e.role)
.unwrap_or_else(|| "<current-role>".to_string());
return Err(format!(
"role changes are not part of `acl update` — they need the compare-and-swap that \
`acl change-role` carries.\n\n Run: pnm acl change-role --did {did} --from \
{current} --to {r}"
)
.into());
}
let approve_scope_echo = approve_scope.clone();
let allowed_keys_echo = allowed_keys.clone();
let req = UpdateAclRequest {
label,
allowed_contexts: contexts,
step_up_approver: step_up_approver.clone(),
step_up_require: step_up_require.clone(),
approve_scope,
allowed_keys,
};
let entry = client.update_acl(did, req).await?;
println!("ACL entry updated:");
println!(" DID: {}", entry.did);
println!(
" Role: {}",
format_role(&entry.role, &entry.allowed_contexts)
);
if let Some(label) = &entry.label {
println!(" Label: {label}");
}
println!(
" Contexts: {}",
format_contexts(&entry.role, &entry.allowed_contexts)
);
if let Some(approver) = &step_up_approver {
if approver.is_empty() {
println!(" Step-up approver: (cleared)");
} else {
println!(" Step-up approver: {approver}");
}
}
if let Some(require) = &step_up_require {
if require.is_empty() {
println!(" Step-up require: (cleared)");
} else {
println!(" Step-up require: {require}");
}
}
if let Some(replacement) = &allowed_keys_echo {
let rendered = match replacement.as_deref() {
None => "(cleared — every key within the entry's contexts)".to_string(),
Some([]) => "(none — may invoke the signing oracle on no keys)".to_string(),
Some(keys) => format!("keys [{}]", keys.join(", ")),
};
println!(" Allowed keys: {rendered}");
}
if let Some(scope) = &approve_scope_echo {
let rendered = match scope {
ApproveScope::None => "(revoked — confers nothing)".to_string(),
ApproveScope::All => "all contexts".to_string(),
ApproveScope::Contexts(cs) => format!("contexts [{}]", cs.join(", ")),
};
println!(" Approve: {rendered}");
}
Ok(())
}
pub async fn cmd_acl_delete(
client: &VtaClient,
did: &str,
) -> Result<(), Box<dyn std::error::Error>> {
client.delete_acl(did).await?;
println!("ACL entry deleted: {did}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_contexts_empty_is_role_dependent() {
assert_eq!(format_contexts("admin", &[]), "(unrestricted)");
for role in ["reader", "initiator", "application"] {
assert_eq!(
format_contexts(role, &[]),
"(none — acts nowhere)",
"empty contexts must not read as unrestricted for role {role}"
);
}
}
#[test]
fn test_least_privilege_approver_does_not_read_as_unrestricted() {
let contexts: Vec<String> = vec![];
assert_eq!(
format_contexts("reader", &contexts),
"(none — acts nowhere)"
);
assert_eq!(format_role("reader", &contexts), "reader");
assert_eq!(
format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
Some("contexts [openvtc]")
);
}
#[test]
fn test_format_approve_scope() {
assert_eq!(
format_approve_scope(true, &[]).as_deref(),
Some("all contexts")
);
assert_eq!(
format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
Some("contexts [openvtc]")
);
assert_eq!(
format_approve_scope(false, &["a".to_string(), "b".to_string()]).as_deref(),
Some("contexts [a, b]")
);
assert_eq!(format_approve_scope(false, &[]), None);
}
#[test]
fn test_format_contexts_single() {
let ctx = vec!["vta".to_string()];
assert_eq!(format_contexts("reader", &ctx), "vta");
}
#[test]
fn test_format_contexts_multiple() {
let ctx = vec!["vta".to_string(), "payments".to_string()];
assert_eq!(format_contexts("reader", &ctx), "vta, payments");
}
#[test]
fn test_format_allowed_keys_distinguishes_absent_from_empty() {
assert_eq!(format_allowed_keys(None), None, "no filter → no line");
assert_eq!(
format_allowed_keys(Some(&[])).as_deref(),
Some("(none — may invoke the signing oracle on no keys)")
);
assert_eq!(
format_allowed_keys(Some(&["k1".to_string(), "k2".to_string()])).as_deref(),
Some("keys [k1, k2]")
);
}
#[test]
fn test_allowed_keys_from_flags() {
assert_eq!(allowed_keys_from_flags(None, false), None);
assert_eq!(
allowed_keys_from_flags(Some(vec!["k1".into()]), false),
Some(Some(vec!["k1".to_string()]))
);
assert_eq!(allowed_keys_from_flags(None, true), Some(None));
}
#[test]
fn test_format_role_admin_no_contexts_is_super_admin() {
assert_eq!(format_role("admin", &[]), "super admin");
}
#[test]
fn test_format_role_admin_with_contexts_stays_admin() {
let ctx = vec!["vta".to_string()];
assert_eq!(format_role("admin", &ctx), "admin");
}
#[test]
fn test_format_role_initiator_unchanged() {
assert_eq!(format_role("initiator", &[]), "initiator");
}
#[test]
fn test_format_role_application_unchanged() {
let ctx = vec!["app".to_string()];
assert_eq!(format_role("application", &ctx), "application");
}
#[test]
fn test_validate_role_admin_ok() {
assert!(validate_role("admin").is_ok());
}
#[test]
fn test_validate_role_initiator_ok() {
assert!(validate_role("initiator").is_ok());
}
#[test]
fn test_validate_role_application_ok() {
assert!(validate_role("application").is_ok());
}
#[test]
fn test_validate_role_reader_ok() {
assert!(validate_role("reader").is_ok());
}
#[test]
fn test_validate_role_unknown_fails() {
let err = validate_role("superuser").unwrap_err();
assert!(err.to_string().contains("invalid role 'superuser'"));
}
#[test]
fn test_validate_role_empty_fails() {
assert!(validate_role("").is_err());
}
}