1use ratatui::{
2 layout::Constraint,
3 style::{Color, Modifier, Style},
4 widgets::{Block, Cell, Row, Table},
5};
6use vta_sdk::acl::{ApproveScope, ContextDirection};
7use vta_sdk::client::ChangeAclRoleRequest;
8use vta_sdk::prelude::*;
9use vti_common::acl::{Role, act_scope_for};
10
11use crate::display::{
12 NAME_HEADER, NameBook, NameSource, book_from_acl, did_cell, full_display_pairs, name_cell,
13 named_did_cell, resolve_agent_names_into,
14};
15use crate::render::{is_full_display, print_full_entry_owned, print_full_list_title, print_widget};
16
17pub fn format_contexts(role: &str, contexts: &[String]) -> String {
30 let role = Role::parse(role).unwrap_or(Role::Monitor);
37 act_scope_for(&role, contexts).to_string()
38}
39
40pub fn format_role(role: &str, contexts: &[String]) -> String {
41 if role == "admin" && contexts.is_empty() {
42 "super admin".to_string()
43 } else {
44 role.to_string()
45 }
46}
47
48pub fn format_approve_scope(approve_all: bool, approve_contexts: &[String]) -> Option<String> {
52 if approve_all {
53 Some("all contexts".to_string())
54 } else if !approve_contexts.is_empty() {
55 Some(format!("contexts [{}]", approve_contexts.join(", ")))
56 } else {
57 None
58 }
59}
60
61pub fn validate_role(role: &str) -> Result<(), Box<dyn std::error::Error>> {
62 match role {
63 "admin" | "initiator" | "application" | "reader" => Ok(()),
64 _ => Err(format!(
65 "invalid role '{role}', expected: admin, initiator, application, or reader"
66 )
67 .into()),
68 }
69}
70
71pub fn parse_direction(
75 direction: Option<&str>,
76) -> Result<ContextDirection, Box<dyn std::error::Error>> {
77 match direction {
78 None => Ok(ContextDirection::default()),
79 Some(d) => Ok(d.parse::<ContextDirection>()?),
80 }
81}
82
83pub fn describe_filter(context: &str, direction: ContextDirection) -> String {
90 match direction {
91 ContextDirection::ActingIn => format!("able to act in {context}"),
92 ContextDirection::Subtree => format!("granted at or beneath {context}"),
93 ContextDirection::Any => format!("with authority touching {context}"),
94 }
95}
96
97pub async fn cmd_acl_list(
98 client: &VtaClient,
99 context: Option<&str>,
100 direction: Option<&str>,
101) -> Result<(), Box<dyn std::error::Error>> {
102 let direction = parse_direction(direction)?;
103 if context.is_none() && direction != ContextDirection::default() {
104 return Err(format!(
105 "--direction {direction} says how to read --context, and no --context was given.\n\
106 Try: pnm acl list --context <id> --direction {direction}"
107 )
108 .into());
109 }
110 let resp = client.list_acl_in_direction(context, direction).await?;
111
112 if crate::render::is_json_output() {
117 crate::render::print_json(&resp.entries)?;
118 return Ok(());
119 }
120
121 if resp.entries.is_empty() {
122 match context {
126 Some(ctx) => println!("No ACL entries found {}.", describe_filter(ctx, direction)),
127 None => println!("No ACL entries found."),
128 }
129 return Ok(());
130 }
131
132 let mut book = NameBook::new();
136 book_from_acl(&mut book, &resp.entries);
137 resolve_agent_names_into(
140 &mut book,
141 resp.entries
142 .iter()
143 .flat_map(|e| [e.did.as_str(), e.created_by.as_str()]),
144 )
145 .await;
146
147 let heading = match context {
152 Some(ctx) => format!("ACL Entries — {}", describe_filter(ctx, direction)),
153 None => "ACL Entries".to_string(),
154 };
155
156 if is_full_display() {
157 print_full_list_title(&heading, resp.entries.len());
158 for entry in &resp.entries {
159 let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
160 let role = format_role(&entry.role, &entry.allowed_contexts);
161 let approve =
162 format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts());
163
164 let mut fields = full_display_pairs(&book, &entry.did);
167 fields.push(("Role", role));
168 if let Some(label) = entry.label.as_deref()
171 && book.name_of(&entry.did).as_deref() != Some(label)
172 {
173 fields.push(("Label", label.to_string()));
174 }
175 fields.push(("Contexts", contexts));
176 if let Some(a) = approve {
177 fields.push(("Approve", a));
178 }
179 fields.push(("Created By", book.render_inline(&entry.created_by)));
180 print_full_entry_owned(&fields);
181 }
182 return Ok(());
183 }
184
185 let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
189
190 let header_style = Style::default()
191 .fg(Color::White)
192 .add_modifier(Modifier::BOLD);
193 let mut header_cells = vec!["DID", "Role", "Contexts", "Created By"];
194 if show_names {
195 header_cells.insert(0, NAME_HEADER);
196 }
197 let header = Row::new(header_cells).style(header_style).bottom_margin(1);
198
199 let rows: Vec<Row> = resp
200 .entries
201 .iter()
202 .map(|entry| {
203 let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
204 let mut cells = vec![
205 did_cell(&entry.did),
206 Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
207 Cell::from(contexts),
208 named_did_cell(&book, &entry.created_by),
209 ];
210 if show_names {
211 cells.insert(0, name_cell(&book, &entry.did));
212 }
213 Row::new(cells)
214 })
215 .collect();
216
217 let title = format!(" {heading} ({}) ", resp.entries.len());
218
219 let mut constraints = vec![
223 Constraint::Min(34), Constraint::Length(12), Constraint::Length(24), Constraint::Min(30), ];
228 if show_names {
229 constraints.insert(0, Constraint::Min(16));
230 }
231
232 let table = Table::new(rows, constraints)
233 .header(header)
234 .column_spacing(2)
235 .block(
236 Block::bordered()
237 .title(title)
238 .border_style(Style::default().fg(Color::DarkGray)),
239 );
240
241 let height = resp.entries.len() as u16 + 4;
242 print_widget(table, height);
243
244 Ok(())
245}
246
247pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
248 let entry = client.get_acl(did).await?;
249
250 let mut book = NameBook::new();
251 book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
252 resolve_agent_names_into(&mut book, [entry.did.as_str()]).await;
253
254 match book.name_of(&entry.did) {
257 Some(name) => {
258 println!("Name: {name}");
259 println!("DID: {}", entry.did);
260 }
261 None => println!("DID: {}", entry.did),
262 }
263 println!(
264 "Role: {}",
265 format_role(&entry.role, &entry.allowed_contexts)
266 );
267 if let Some(label) = entry.label.as_deref()
270 && book.name_of(&entry.did).as_deref() != Some(label)
271 {
272 println!("Label: {label}");
273 }
274 println!(
275 "Contexts: {}",
276 format_contexts(&entry.role, &entry.allowed_contexts)
277 );
278 if let Some(scope) =
279 format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
280 {
281 println!("Approve: {scope}");
282 }
283 println!("Created At: {}", entry.created_at);
284 println!("Created By: {}", entry.created_by);
285 Ok(())
286}
287
288pub async fn cmd_acl_create(
289 client: &VtaClient,
290 did: String,
291 role: String,
292 label: Option<String>,
293 contexts: Vec<String>,
294 expires_at: Option<u64>,
295 step_up_approver: Option<String>,
296 step_up_require: Option<String>,
297 approve_all: bool,
298 approve_contexts: Vec<String>,
299) -> Result<(), Box<dyn std::error::Error>> {
300 validate_role(&role)?;
301 let mut req = CreateAclRequest::new(did, role).contexts(contexts);
302 if let Some(l) = label {
303 req = req.label(l);
304 }
305 if let Some(secs) = expires_at {
306 req = req.expires_at(secs);
307 }
308 if let Some(ref approver) = step_up_approver {
309 req = req.step_up_approver(approver.clone());
310 }
311 if let Some(ref require) = step_up_require {
312 req = req.step_up_require(require.clone());
313 }
314 if approve_all {
315 req = req.approve_all();
316 } else if !approve_contexts.is_empty() {
317 req = req.approve_contexts(approve_contexts);
318 }
319 let entry = client.create_acl(req).await?;
320 println!("ACL entry created:");
321 println!(" DID: {}", entry.did);
322 println!(
323 " Role: {}",
324 format_role(&entry.role, &entry.allowed_contexts)
325 );
326 if let Some(label) = &entry.label {
327 println!(" Label: {label}");
328 }
329 println!(
330 " Contexts: {}",
331 format_contexts(&entry.role, &entry.allowed_contexts)
332 );
333 if let Some(scope) =
334 format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
335 {
336 println!(" Approve: {scope}");
337 }
338 if let Some(approver) = &step_up_approver {
339 println!(" Step-up approver: {approver}");
340 }
341 if let Some(require) = &step_up_require {
342 println!(" Step-up require: {require}");
343 }
344 match entry.expires_at {
345 Some(secs) => println!(
346 " Expires at: {} ({})",
347 crate::duration::format_local_time(secs),
348 crate::duration::format_remaining(secs),
349 ),
350 None => println!(" Expires at: (permanent)"),
351 }
352 Ok(())
353}
354
355pub fn approve_scope_from_flags(
361 approve_all: bool,
362 approve_contexts: Option<Vec<String>>,
363 approve_none: bool,
364) -> Option<ApproveScope> {
365 if approve_none {
366 Some(ApproveScope::None)
367 } else if approve_all {
368 Some(ApproveScope::All)
369 } else {
370 approve_contexts.map(ApproveScope::Contexts)
371 }
372}
373
374pub async fn cmd_acl_change_role(
377 client: &VtaClient,
378 did: &str,
379 from_role: &str,
380 to_role: &str,
381 reason: Option<String>,
382) -> Result<(), Box<dyn std::error::Error>> {
383 validate_role(from_role)?;
384 validate_role(to_role)?;
385
386 let entry = client
387 .change_acl_role(
388 did,
389 ChangeAclRoleRequest {
390 from_role: from_role.to_string(),
391 to_role: to_role.to_string(),
392 reason,
393 },
394 )
395 .await?;
396
397 println!("ACL role changed:");
398 println!(" DID: {}", entry.did);
399 println!(
400 " Role: {} \u{2192} {}",
401 from_role,
402 format_role(&entry.role, &entry.allowed_contexts)
403 );
404 Ok(())
405}
406
407#[allow(clippy::too_many_arguments)]
408pub async fn cmd_acl_update(
409 client: &VtaClient,
410 did: &str,
411 role: Option<String>,
412 label: Option<String>,
413 contexts: Option<Vec<String>>,
414 step_up_approver: Option<String>,
415 step_up_require: Option<String>,
416 approve_scope: Option<ApproveScope>,
417) -> Result<(), Box<dyn std::error::Error>> {
418 if let Some(ref r) = role {
424 validate_role(r)?;
425 let current = client
426 .get_acl(did)
427 .await
428 .ok()
429 .map(|e| e.role)
430 .unwrap_or_else(|| "<current-role>".to_string());
431 return Err(format!(
432 "role changes are not part of `acl update` — they need the compare-and-swap that \
433 `acl change-role` carries.\n\n Run: pnm acl change-role --did {did} --from \
434 {current} --to {r}"
435 )
436 .into());
437 }
438 let approve_scope_echo = approve_scope.clone();
439 let req = UpdateAclRequest {
440 label,
441 allowed_contexts: contexts,
442 step_up_approver: step_up_approver.clone(),
443 step_up_require: step_up_require.clone(),
444 approve_scope,
445 };
446 let entry = client.update_acl(did, req).await?;
447 println!("ACL entry updated:");
448 println!(" DID: {}", entry.did);
449 println!(
450 " Role: {}",
451 format_role(&entry.role, &entry.allowed_contexts)
452 );
453 if let Some(label) = &entry.label {
454 println!(" Label: {label}");
455 }
456 println!(
457 " Contexts: {}",
458 format_contexts(&entry.role, &entry.allowed_contexts)
459 );
460 if let Some(approver) = &step_up_approver {
461 if approver.is_empty() {
462 println!(" Step-up approver: (cleared)");
463 } else {
464 println!(" Step-up approver: {approver}");
465 }
466 }
467 if let Some(require) = &step_up_require {
468 if require.is_empty() {
469 println!(" Step-up require: (cleared)");
470 } else {
471 println!(" Step-up require: {require}");
472 }
473 }
474 if let Some(scope) = &approve_scope_echo {
477 let rendered = match scope {
478 ApproveScope::None => "(revoked — confers nothing)".to_string(),
479 ApproveScope::All => "all contexts".to_string(),
480 ApproveScope::Contexts(cs) => format!("contexts [{}]", cs.join(", ")),
481 };
482 println!(" Approve: {rendered}");
483 }
484 Ok(())
485}
486
487pub async fn cmd_acl_delete(
488 client: &VtaClient,
489 did: &str,
490) -> Result<(), Box<dyn std::error::Error>> {
491 client.delete_acl(did).await?;
492 println!("ACL entry deleted: {did}");
493 Ok(())
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
506 fn test_format_contexts_empty_is_role_dependent() {
507 assert_eq!(format_contexts("admin", &[]), "(unrestricted)");
508 for role in ["reader", "initiator", "application"] {
509 assert_eq!(
510 format_contexts(role, &[]),
511 "(none — acts nowhere)",
512 "empty contexts must not read as unrestricted for role {role}"
513 );
514 }
515 }
516
517 #[test]
521 fn test_least_privilege_approver_does_not_read_as_unrestricted() {
522 let contexts: Vec<String> = vec![];
523 assert_eq!(
524 format_contexts("reader", &contexts),
525 "(none — acts nowhere)"
526 );
527 assert_eq!(format_role("reader", &contexts), "reader");
528 assert_eq!(
529 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
530 Some("contexts [openvtc]")
531 );
532 }
533
534 #[test]
535 fn test_format_approve_scope() {
536 assert_eq!(
537 format_approve_scope(true, &[]).as_deref(),
538 Some("all contexts")
539 );
540 assert_eq!(
541 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
542 Some("contexts [openvtc]")
543 );
544 assert_eq!(
545 format_approve_scope(false, &["a".to_string(), "b".to_string()]).as_deref(),
546 Some("contexts [a, b]")
547 );
548 assert_eq!(format_approve_scope(false, &[]), None);
550 }
551
552 #[test]
553 fn test_format_contexts_single() {
554 let ctx = vec!["vta".to_string()];
555 assert_eq!(format_contexts("reader", &ctx), "vta");
556 }
557
558 #[test]
559 fn test_format_contexts_multiple() {
560 let ctx = vec!["vta".to_string(), "payments".to_string()];
561 assert_eq!(format_contexts("reader", &ctx), "vta, payments");
562 }
563
564 #[test]
567 fn test_format_role_admin_no_contexts_is_super_admin() {
568 assert_eq!(format_role("admin", &[]), "super admin");
569 }
570
571 #[test]
572 fn test_format_role_admin_with_contexts_stays_admin() {
573 let ctx = vec!["vta".to_string()];
574 assert_eq!(format_role("admin", &ctx), "admin");
575 }
576
577 #[test]
578 fn test_format_role_initiator_unchanged() {
579 assert_eq!(format_role("initiator", &[]), "initiator");
580 }
581
582 #[test]
583 fn test_format_role_application_unchanged() {
584 let ctx = vec!["app".to_string()];
585 assert_eq!(format_role("application", &ctx), "application");
586 }
587
588 #[test]
591 fn test_validate_role_admin_ok() {
592 assert!(validate_role("admin").is_ok());
593 }
594
595 #[test]
596 fn test_validate_role_initiator_ok() {
597 assert!(validate_role("initiator").is_ok());
598 }
599
600 #[test]
601 fn test_validate_role_application_ok() {
602 assert!(validate_role("application").is_ok());
603 }
604
605 #[test]
606 fn test_validate_role_reader_ok() {
607 assert!(validate_role("reader").is_ok());
608 }
609
610 #[test]
611 fn test_validate_role_unknown_fails() {
612 let err = validate_role("superuser").unwrap_err();
613 assert!(err.to_string().contains("invalid role 'superuser'"));
614 }
615
616 #[test]
617 fn test_validate_role_empty_fails() {
618 assert!(validate_role("").is_err());
619 }
620}