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 format_allowed_keys(allowed_keys: Option<&[String]>) -> Option<String> {
66 match allowed_keys {
67 None => None,
68 Some([]) => Some("(none — may invoke the signing oracle on no keys)".to_string()),
69 Some(keys) => Some(format!("keys [{}]", keys.join(", "))),
70 }
71}
72
73pub fn allowed_keys_from_flags(
80 allowed_keys: Option<Vec<String>>,
81 allowed_keys_unrestricted: bool,
82) -> Option<Option<Vec<String>>> {
83 if allowed_keys_unrestricted {
84 Some(None)
85 } else {
86 allowed_keys.map(Some)
87 }
88}
89
90pub fn capabilities_from_flags(
99 capabilities: Option<Vec<String>>,
100 capabilities_all: bool,
101) -> Option<Vec<String>> {
102 if capabilities_all {
103 Some(Vec::new())
104 } else {
105 capabilities
106 }
107}
108
109pub fn validate_role(role: &str) -> Result<(), Box<dyn std::error::Error>> {
110 match role {
111 "admin" | "initiator" | "application" | "reader" => Ok(()),
112 _ => Err(format!(
113 "invalid role '{role}', expected: admin, initiator, application, or reader"
114 )
115 .into()),
116 }
117}
118
119pub fn parse_direction(
123 direction: Option<&str>,
124) -> Result<ContextDirection, Box<dyn std::error::Error>> {
125 match direction {
126 None => Ok(ContextDirection::default()),
127 Some(d) => Ok(d.parse::<ContextDirection>()?),
128 }
129}
130
131pub fn describe_filter(context: &str, direction: ContextDirection) -> String {
138 match direction {
139 ContextDirection::ActingIn => format!("able to act in {context}"),
140 ContextDirection::Subtree => format!("granted at or beneath {context}"),
141 ContextDirection::Any => format!("with authority touching {context}"),
142 }
143}
144
145pub async fn cmd_acl_list(
146 client: &VtaClient,
147 context: Option<&str>,
148 direction: Option<&str>,
149) -> Result<(), Box<dyn std::error::Error>> {
150 let direction = parse_direction(direction)?;
151 if context.is_none() && direction != ContextDirection::default() {
152 return Err(format!(
153 "--direction {direction} says how to read --context, and no --context was given.\n\
154 Try: pnm acl list --context <id> --direction {direction}"
155 )
156 .into());
157 }
158 let resp = client.list_acl_in_direction(context, direction).await?;
159
160 if crate::render::is_json_output() {
165 crate::render::print_json(&resp.entries)?;
166 return Ok(());
167 }
168
169 if resp.entries.is_empty() {
170 match context {
174 Some(ctx) => println!("No ACL entries found {}.", describe_filter(ctx, direction)),
175 None => println!("No ACL entries found."),
176 }
177 return Ok(());
178 }
179
180 let mut book = NameBook::new();
184 book_from_acl(&mut book, &resp.entries);
185 resolve_agent_names_into(
188 &mut book,
189 resp.entries
190 .iter()
191 .flat_map(|e| [e.did.as_str(), e.created_by.as_str()]),
192 )
193 .await;
194
195 let heading = match context {
200 Some(ctx) => format!("ACL Entries — {}", describe_filter(ctx, direction)),
201 None => "ACL Entries".to_string(),
202 };
203
204 if is_full_display() {
205 print_full_list_title(&heading, resp.entries.len());
206 for entry in &resp.entries {
207 let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
208 let role = format_role(&entry.role, &entry.allowed_contexts);
209 let approve =
210 format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts());
211
212 let mut fields = full_display_pairs(&book, &entry.did);
215 fields.push(("Role", role));
216 if let Some(label) = entry.label.as_deref()
219 && book.name_of(&entry.did).as_deref() != Some(label)
220 {
221 fields.push(("Label", label.to_string()));
222 }
223 fields.push(("Contexts", contexts));
224 if let Some(k) = format_allowed_keys(entry.allowed_keys.as_deref()) {
225 fields.push(("Allowed Keys", k));
226 }
227 if let Some(a) = approve {
228 fields.push(("Approve", a));
229 }
230 fields.push(("Created By", book.render_inline(&entry.created_by)));
231 print_full_entry_owned(&fields);
232 }
233 return Ok(());
234 }
235
236 let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
240
241 let header_style = Style::default()
242 .fg(Color::White)
243 .add_modifier(Modifier::BOLD);
244 let mut header_cells = vec!["DID", "Role", "Contexts", "Created By"];
245 if show_names {
246 header_cells.insert(0, NAME_HEADER);
247 }
248 let header = Row::new(header_cells).style(header_style).bottom_margin(1);
249
250 let rows: Vec<Row> = resp
251 .entries
252 .iter()
253 .map(|entry| {
254 let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
255 let mut cells = vec![
256 did_cell(&entry.did),
257 Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
258 Cell::from(contexts),
259 named_did_cell(&book, &entry.created_by),
260 ];
261 if show_names {
262 cells.insert(0, name_cell(&book, &entry.did));
263 }
264 Row::new(cells)
265 })
266 .collect();
267
268 let title = format!(" {heading} ({}) ", resp.entries.len());
269
270 let mut constraints = vec![
274 Constraint::Min(34), Constraint::Length(12), Constraint::Length(24), Constraint::Min(30), ];
279 if show_names {
280 constraints.insert(0, Constraint::Min(16));
281 }
282
283 let table = Table::new(rows, constraints)
284 .header(header)
285 .column_spacing(2)
286 .block(
287 Block::bordered()
288 .title(title)
289 .border_style(Style::default().fg(Color::DarkGray)),
290 );
291
292 let height = resp.entries.len() as u16 + 4;
293 print_widget(table, height);
294
295 Ok(())
296}
297
298pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
299 let entry = client.get_acl(did).await?;
300
301 let mut book = NameBook::new();
302 book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
303 resolve_agent_names_into(&mut book, [entry.did.as_str()]).await;
304
305 match book.name_of(&entry.did) {
308 Some(name) => {
309 println!("Name: {name}");
310 println!("DID: {}", entry.did);
311 }
312 None => println!("DID: {}", entry.did),
313 }
314 println!(
315 "Role: {}",
316 format_role(&entry.role, &entry.allowed_contexts)
317 );
318 if let Some(label) = entry.label.as_deref()
321 && book.name_of(&entry.did).as_deref() != Some(label)
322 {
323 println!("Label: {label}");
324 }
325 println!(
326 "Contexts: {}",
327 format_contexts(&entry.role, &entry.allowed_contexts)
328 );
329 if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
330 println!("Allowed keys: {keys}");
331 }
332 let held = entry.capabilities();
333 if !held.is_empty() {
334 println!("Capabilities: {}", held.join(", "));
335 }
336 if let Some(scope) =
337 format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
338 {
339 println!("Approve: {scope}");
340 }
341 println!("Created At: {}", entry.created_at);
342 println!("Created By: {}", entry.created_by);
343 Ok(())
344}
345
346#[allow(clippy::too_many_arguments)]
347pub async fn cmd_acl_create(
348 client: &VtaClient,
349 did: String,
350 role: String,
351 label: Option<String>,
352 contexts: Vec<String>,
353 expires_at: Option<u64>,
354 step_up_approver: Option<String>,
355 step_up_require: Option<String>,
356 approve_all: bool,
357 approve_contexts: Vec<String>,
358 allowed_keys: Option<Vec<String>>,
359 capabilities: Option<Vec<String>>,
360) -> Result<(), Box<dyn std::error::Error>> {
361 validate_role(&role)?;
362 let mut req = CreateAclRequest::new(did, role).contexts(contexts);
363 if let Some(ref caps) = capabilities {
364 req = req.capabilities(caps.clone());
365 }
366 if let Some(keys) = allowed_keys {
367 req = req.allowed_keys(keys);
368 }
369 if let Some(l) = label {
370 req = req.label(l);
371 }
372 if let Some(secs) = expires_at {
373 req = req.expires_at(secs);
374 }
375 if let Some(ref approver) = step_up_approver {
376 req = req.step_up_approver(approver.clone());
377 }
378 if let Some(ref require) = step_up_require {
379 req = req.step_up_require(require.clone());
380 }
381 if approve_all {
382 req = req.approve_all();
383 } else if !approve_contexts.is_empty() {
384 req = req.approve_contexts(approve_contexts);
385 }
386 let entry = client.create_acl(req).await?;
387 println!("ACL entry created:");
388 println!(" DID: {}", entry.did);
389 println!(
390 " Role: {}",
391 format_role(&entry.role, &entry.allowed_contexts)
392 );
393 if let Some(label) = &entry.label {
394 println!(" Label: {label}");
395 }
396 println!(
397 " Contexts: {}",
398 format_contexts(&entry.role, &entry.allowed_contexts)
399 );
400 if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
401 println!(" Allowed keys: {keys}");
402 }
403 let held = entry.capabilities();
406 if !held.is_empty() {
407 println!(" Capabilities: {}", held.join(", "));
408 }
409 if let Some(scope) =
410 format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
411 {
412 println!(" Approve: {scope}");
413 }
414 if let Some(approver) = &step_up_approver {
415 println!(" Step-up approver: {approver}");
416 }
417 if let Some(require) = &step_up_require {
418 println!(" Step-up require: {require}");
419 }
420 match entry.expires_at {
421 Some(secs) => println!(
422 " Expires at: {} ({})",
423 crate::duration::format_local_time(secs),
424 crate::duration::format_remaining(secs),
425 ),
426 None => println!(" Expires at: (permanent)"),
427 }
428 Ok(())
429}
430
431pub fn approve_scope_from_flags(
437 approve_all: bool,
438 approve_contexts: Option<Vec<String>>,
439 approve_none: bool,
440) -> Option<ApproveScope> {
441 if approve_none {
442 Some(ApproveScope::None)
443 } else if approve_all {
444 Some(ApproveScope::All)
445 } else {
446 approve_contexts.map(ApproveScope::Contexts)
447 }
448}
449
450pub async fn cmd_acl_change_role(
453 client: &VtaClient,
454 did: &str,
455 from_role: &str,
456 to_role: &str,
457 reason: Option<String>,
458) -> Result<(), Box<dyn std::error::Error>> {
459 validate_role(from_role)?;
460 validate_role(to_role)?;
461
462 let entry = client
463 .change_acl_role(
464 did,
465 ChangeAclRoleRequest {
466 from_role: from_role.to_string(),
467 to_role: to_role.to_string(),
468 reason,
469 },
470 )
471 .await?;
472
473 println!("ACL role changed:");
474 println!(" DID: {}", entry.did);
475 println!(
476 " Role: {} \u{2192} {}",
477 from_role,
478 format_role(&entry.role, &entry.allowed_contexts)
479 );
480 Ok(())
481}
482
483#[allow(clippy::too_many_arguments)]
484pub async fn cmd_acl_update(
485 client: &VtaClient,
486 did: &str,
487 role: Option<String>,
488 label: Option<String>,
489 contexts: Option<Vec<String>>,
490 step_up_approver: Option<String>,
491 step_up_require: Option<String>,
492 approve_scope: Option<ApproveScope>,
493 allowed_keys: Option<Option<Vec<String>>>,
494 capabilities: Option<Vec<String>>,
495) -> Result<(), Box<dyn std::error::Error>> {
496 if let Some(ref r) = role {
502 validate_role(r)?;
503 let current = client
504 .get_acl(did)
505 .await
506 .ok()
507 .map(|e| e.role)
508 .unwrap_or_else(|| "<current-role>".to_string());
509 return Err(format!(
510 "role changes are not part of `acl update` — they need the compare-and-swap that \
511 `acl change-role` carries.\n\n Run: pnm acl change-role --did {did} --from \
512 {current} --to {r}"
513 )
514 .into());
515 }
516 let approve_scope_echo = approve_scope.clone();
517 let allowed_keys_echo = allowed_keys.clone();
518 let req = UpdateAclRequest {
519 label,
520 allowed_contexts: contexts,
521 step_up_approver: step_up_approver.clone(),
522 step_up_require: step_up_require.clone(),
523 approve_scope,
524 allowed_keys,
525 capabilities: capabilities.clone(),
526 };
527 let entry = client.update_acl(did, req).await?;
528 println!("ACL entry updated:");
529 println!(" DID: {}", entry.did);
530 println!(
531 " Role: {}",
532 format_role(&entry.role, &entry.allowed_contexts)
533 );
534 if let Some(label) = &entry.label {
535 println!(" Label: {label}");
536 }
537 println!(
538 " Contexts: {}",
539 format_contexts(&entry.role, &entry.allowed_contexts)
540 );
541 if let Some(approver) = &step_up_approver {
542 if approver.is_empty() {
543 println!(" Step-up approver: (cleared)");
544 } else {
545 println!(" Step-up approver: {approver}");
546 }
547 }
548 if let Some(require) = &step_up_require {
549 if require.is_empty() {
550 println!(" Step-up require: (cleared)");
551 } else {
552 println!(" Step-up require: {require}");
553 }
554 }
555 if let Some(replacement) = &allowed_keys_echo {
559 let rendered = match replacement.as_deref() {
560 None => "(cleared — every key within the entry's contexts)".to_string(),
561 Some([]) => "(none — may invoke the signing oracle on no keys)".to_string(),
562 Some(keys) => format!("keys [{}]", keys.join(", ")),
563 };
564 println!(" Allowed keys: {rendered}");
565 }
566 if let Some(scope) = &approve_scope_echo {
569 let rendered = match scope {
570 ApproveScope::None => "(revoked — confers nothing)".to_string(),
571 ApproveScope::All => "all contexts".to_string(),
572 ApproveScope::Contexts(cs) => format!("contexts [{}]", cs.join(", ")),
573 };
574 println!(" Approve: {rendered}");
575 }
576 if capabilities.is_some() {
580 let held = entry.capabilities();
581 let rendered = if held.is_empty() {
582 "(cleared — everything the role allows)".to_string()
583 } else {
584 held.join(", ")
585 };
586 println!(" Capabilities: {rendered}");
587 }
588 Ok(())
589}
590
591pub async fn cmd_acl_delete(
592 client: &VtaClient,
593 did: &str,
594) -> Result<(), Box<dyn std::error::Error>> {
595 client.delete_acl(did).await?;
596 println!("ACL entry deleted: {did}");
597 Ok(())
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
610 fn test_format_contexts_empty_is_role_dependent() {
611 assert_eq!(format_contexts("admin", &[]), "(unrestricted)");
612 for role in ["reader", "initiator", "application"] {
613 assert_eq!(
614 format_contexts(role, &[]),
615 "(none — acts nowhere)",
616 "empty contexts must not read as unrestricted for role {role}"
617 );
618 }
619 }
620
621 #[test]
625 fn test_least_privilege_approver_does_not_read_as_unrestricted() {
626 let contexts: Vec<String> = vec![];
627 assert_eq!(
628 format_contexts("reader", &contexts),
629 "(none — acts nowhere)"
630 );
631 assert_eq!(format_role("reader", &contexts), "reader");
632 assert_eq!(
633 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
634 Some("contexts [openvtc]")
635 );
636 }
637
638 #[test]
639 fn test_format_approve_scope() {
640 assert_eq!(
641 format_approve_scope(true, &[]).as_deref(),
642 Some("all contexts")
643 );
644 assert_eq!(
645 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
646 Some("contexts [openvtc]")
647 );
648 assert_eq!(
649 format_approve_scope(false, &["a".to_string(), "b".to_string()]).as_deref(),
650 Some("contexts [a, b]")
651 );
652 assert_eq!(format_approve_scope(false, &[]), None);
654 }
655
656 #[test]
657 fn test_format_contexts_single() {
658 let ctx = vec!["vta".to_string()];
659 assert_eq!(format_contexts("reader", &ctx), "vta");
660 }
661
662 #[test]
663 fn test_format_contexts_multiple() {
664 let ctx = vec!["vta".to_string(), "payments".to_string()];
665 assert_eq!(format_contexts("reader", &ctx), "vta, payments");
666 }
667
668 #[test]
673 fn test_format_allowed_keys_distinguishes_absent_from_empty() {
674 assert_eq!(format_allowed_keys(None), None, "no filter → no line");
675 assert_eq!(
676 format_allowed_keys(Some(&[])).as_deref(),
677 Some("(none — may invoke the signing oracle on no keys)")
678 );
679 assert_eq!(
680 format_allowed_keys(Some(&["k1".to_string(), "k2".to_string()])).as_deref(),
681 Some("keys [k1, k2]")
682 );
683 }
684
685 #[test]
686 fn test_allowed_keys_from_flags() {
687 assert_eq!(allowed_keys_from_flags(None, false), None);
689 assert_eq!(
691 allowed_keys_from_flags(Some(vec!["k1".into()]), false),
692 Some(Some(vec!["k1".to_string()]))
693 );
694 assert_eq!(allowed_keys_from_flags(None, true), Some(None));
697 }
698
699 #[test]
702 fn test_format_role_admin_no_contexts_is_super_admin() {
703 assert_eq!(format_role("admin", &[]), "super admin");
704 }
705
706 #[test]
707 fn test_format_role_admin_with_contexts_stays_admin() {
708 let ctx = vec!["vta".to_string()];
709 assert_eq!(format_role("admin", &ctx), "admin");
710 }
711
712 #[test]
713 fn test_format_role_initiator_unchanged() {
714 assert_eq!(format_role("initiator", &[]), "initiator");
715 }
716
717 #[test]
718 fn test_format_role_application_unchanged() {
719 let ctx = vec!["app".to_string()];
720 assert_eq!(format_role("application", &ctx), "application");
721 }
722
723 #[test]
726 fn test_validate_role_admin_ok() {
727 assert!(validate_role("admin").is_ok());
728 }
729
730 #[test]
731 fn test_validate_role_initiator_ok() {
732 assert!(validate_role("initiator").is_ok());
733 }
734
735 #[test]
736 fn test_validate_role_application_ok() {
737 assert!(validate_role("application").is_ok());
738 }
739
740 #[test]
741 fn test_validate_role_reader_ok() {
742 assert!(validate_role("reader").is_ok());
743 }
744
745 #[test]
746 fn test_validate_role_unknown_fails() {
747 let err = validate_role("superuser").unwrap_err();
748 assert!(err.to_string().contains("invalid role 'superuser'"));
749 }
750
751 #[test]
752 fn test_validate_role_empty_fails() {
753 assert!(validate_role("").is_err());
754 }
755}