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 validate_role(role: &str) -> Result<(), Box<dyn std::error::Error>> {
91 match role {
92 "admin" | "initiator" | "application" | "reader" => Ok(()),
93 _ => Err(format!(
94 "invalid role '{role}', expected: admin, initiator, application, or reader"
95 )
96 .into()),
97 }
98}
99
100pub fn parse_direction(
104 direction: Option<&str>,
105) -> Result<ContextDirection, Box<dyn std::error::Error>> {
106 match direction {
107 None => Ok(ContextDirection::default()),
108 Some(d) => Ok(d.parse::<ContextDirection>()?),
109 }
110}
111
112pub fn describe_filter(context: &str, direction: ContextDirection) -> String {
119 match direction {
120 ContextDirection::ActingIn => format!("able to act in {context}"),
121 ContextDirection::Subtree => format!("granted at or beneath {context}"),
122 ContextDirection::Any => format!("with authority touching {context}"),
123 }
124}
125
126pub async fn cmd_acl_list(
127 client: &VtaClient,
128 context: Option<&str>,
129 direction: Option<&str>,
130) -> Result<(), Box<dyn std::error::Error>> {
131 let direction = parse_direction(direction)?;
132 if context.is_none() && direction != ContextDirection::default() {
133 return Err(format!(
134 "--direction {direction} says how to read --context, and no --context was given.\n\
135 Try: pnm acl list --context <id> --direction {direction}"
136 )
137 .into());
138 }
139 let resp = client.list_acl_in_direction(context, direction).await?;
140
141 if crate::render::is_json_output() {
146 crate::render::print_json(&resp.entries)?;
147 return Ok(());
148 }
149
150 if resp.entries.is_empty() {
151 match context {
155 Some(ctx) => println!("No ACL entries found {}.", describe_filter(ctx, direction)),
156 None => println!("No ACL entries found."),
157 }
158 return Ok(());
159 }
160
161 let mut book = NameBook::new();
165 book_from_acl(&mut book, &resp.entries);
166 resolve_agent_names_into(
169 &mut book,
170 resp.entries
171 .iter()
172 .flat_map(|e| [e.did.as_str(), e.created_by.as_str()]),
173 )
174 .await;
175
176 let heading = match context {
181 Some(ctx) => format!("ACL Entries — {}", describe_filter(ctx, direction)),
182 None => "ACL Entries".to_string(),
183 };
184
185 if is_full_display() {
186 print_full_list_title(&heading, resp.entries.len());
187 for entry in &resp.entries {
188 let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
189 let role = format_role(&entry.role, &entry.allowed_contexts);
190 let approve =
191 format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts());
192
193 let mut fields = full_display_pairs(&book, &entry.did);
196 fields.push(("Role", role));
197 if let Some(label) = entry.label.as_deref()
200 && book.name_of(&entry.did).as_deref() != Some(label)
201 {
202 fields.push(("Label", label.to_string()));
203 }
204 fields.push(("Contexts", contexts));
205 if let Some(k) = format_allowed_keys(entry.allowed_keys.as_deref()) {
206 fields.push(("Allowed Keys", k));
207 }
208 if let Some(a) = approve {
209 fields.push(("Approve", a));
210 }
211 fields.push(("Created By", book.render_inline(&entry.created_by)));
212 print_full_entry_owned(&fields);
213 }
214 return Ok(());
215 }
216
217 let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
221
222 let header_style = Style::default()
223 .fg(Color::White)
224 .add_modifier(Modifier::BOLD);
225 let mut header_cells = vec!["DID", "Role", "Contexts", "Created By"];
226 if show_names {
227 header_cells.insert(0, NAME_HEADER);
228 }
229 let header = Row::new(header_cells).style(header_style).bottom_margin(1);
230
231 let rows: Vec<Row> = resp
232 .entries
233 .iter()
234 .map(|entry| {
235 let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
236 let mut cells = vec![
237 did_cell(&entry.did),
238 Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
239 Cell::from(contexts),
240 named_did_cell(&book, &entry.created_by),
241 ];
242 if show_names {
243 cells.insert(0, name_cell(&book, &entry.did));
244 }
245 Row::new(cells)
246 })
247 .collect();
248
249 let title = format!(" {heading} ({}) ", resp.entries.len());
250
251 let mut constraints = vec![
255 Constraint::Min(34), Constraint::Length(12), Constraint::Length(24), Constraint::Min(30), ];
260 if show_names {
261 constraints.insert(0, Constraint::Min(16));
262 }
263
264 let table = Table::new(rows, constraints)
265 .header(header)
266 .column_spacing(2)
267 .block(
268 Block::bordered()
269 .title(title)
270 .border_style(Style::default().fg(Color::DarkGray)),
271 );
272
273 let height = resp.entries.len() as u16 + 4;
274 print_widget(table, height);
275
276 Ok(())
277}
278
279pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
280 let entry = client.get_acl(did).await?;
281
282 let mut book = NameBook::new();
283 book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
284 resolve_agent_names_into(&mut book, [entry.did.as_str()]).await;
285
286 match book.name_of(&entry.did) {
289 Some(name) => {
290 println!("Name: {name}");
291 println!("DID: {}", entry.did);
292 }
293 None => println!("DID: {}", entry.did),
294 }
295 println!(
296 "Role: {}",
297 format_role(&entry.role, &entry.allowed_contexts)
298 );
299 if let Some(label) = entry.label.as_deref()
302 && book.name_of(&entry.did).as_deref() != Some(label)
303 {
304 println!("Label: {label}");
305 }
306 println!(
307 "Contexts: {}",
308 format_contexts(&entry.role, &entry.allowed_contexts)
309 );
310 if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
311 println!("Allowed keys: {keys}");
312 }
313 if let Some(scope) =
314 format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
315 {
316 println!("Approve: {scope}");
317 }
318 println!("Created At: {}", entry.created_at);
319 println!("Created By: {}", entry.created_by);
320 Ok(())
321}
322
323#[allow(clippy::too_many_arguments)]
324pub async fn cmd_acl_create(
325 client: &VtaClient,
326 did: String,
327 role: String,
328 label: Option<String>,
329 contexts: Vec<String>,
330 expires_at: Option<u64>,
331 step_up_approver: Option<String>,
332 step_up_require: Option<String>,
333 approve_all: bool,
334 approve_contexts: Vec<String>,
335 allowed_keys: Option<Vec<String>>,
336) -> Result<(), Box<dyn std::error::Error>> {
337 validate_role(&role)?;
338 let mut req = CreateAclRequest::new(did, role).contexts(contexts);
339 if let Some(keys) = allowed_keys {
340 req = req.allowed_keys(keys);
341 }
342 if let Some(l) = label {
343 req = req.label(l);
344 }
345 if let Some(secs) = expires_at {
346 req = req.expires_at(secs);
347 }
348 if let Some(ref approver) = step_up_approver {
349 req = req.step_up_approver(approver.clone());
350 }
351 if let Some(ref require) = step_up_require {
352 req = req.step_up_require(require.clone());
353 }
354 if approve_all {
355 req = req.approve_all();
356 } else if !approve_contexts.is_empty() {
357 req = req.approve_contexts(approve_contexts);
358 }
359 let entry = client.create_acl(req).await?;
360 println!("ACL entry created:");
361 println!(" DID: {}", entry.did);
362 println!(
363 " Role: {}",
364 format_role(&entry.role, &entry.allowed_contexts)
365 );
366 if let Some(label) = &entry.label {
367 println!(" Label: {label}");
368 }
369 println!(
370 " Contexts: {}",
371 format_contexts(&entry.role, &entry.allowed_contexts)
372 );
373 if let Some(keys) = format_allowed_keys(entry.allowed_keys.as_deref()) {
374 println!(" Allowed keys: {keys}");
375 }
376 if let Some(scope) =
377 format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
378 {
379 println!(" Approve: {scope}");
380 }
381 if let Some(approver) = &step_up_approver {
382 println!(" Step-up approver: {approver}");
383 }
384 if let Some(require) = &step_up_require {
385 println!(" Step-up require: {require}");
386 }
387 match entry.expires_at {
388 Some(secs) => println!(
389 " Expires at: {} ({})",
390 crate::duration::format_local_time(secs),
391 crate::duration::format_remaining(secs),
392 ),
393 None => println!(" Expires at: (permanent)"),
394 }
395 Ok(())
396}
397
398pub fn approve_scope_from_flags(
404 approve_all: bool,
405 approve_contexts: Option<Vec<String>>,
406 approve_none: bool,
407) -> Option<ApproveScope> {
408 if approve_none {
409 Some(ApproveScope::None)
410 } else if approve_all {
411 Some(ApproveScope::All)
412 } else {
413 approve_contexts.map(ApproveScope::Contexts)
414 }
415}
416
417pub async fn cmd_acl_change_role(
420 client: &VtaClient,
421 did: &str,
422 from_role: &str,
423 to_role: &str,
424 reason: Option<String>,
425) -> Result<(), Box<dyn std::error::Error>> {
426 validate_role(from_role)?;
427 validate_role(to_role)?;
428
429 let entry = client
430 .change_acl_role(
431 did,
432 ChangeAclRoleRequest {
433 from_role: from_role.to_string(),
434 to_role: to_role.to_string(),
435 reason,
436 },
437 )
438 .await?;
439
440 println!("ACL role changed:");
441 println!(" DID: {}", entry.did);
442 println!(
443 " Role: {} \u{2192} {}",
444 from_role,
445 format_role(&entry.role, &entry.allowed_contexts)
446 );
447 Ok(())
448}
449
450#[allow(clippy::too_many_arguments)]
451pub async fn cmd_acl_update(
452 client: &VtaClient,
453 did: &str,
454 role: Option<String>,
455 label: Option<String>,
456 contexts: Option<Vec<String>>,
457 step_up_approver: Option<String>,
458 step_up_require: Option<String>,
459 approve_scope: Option<ApproveScope>,
460 allowed_keys: Option<Option<Vec<String>>>,
461) -> Result<(), Box<dyn std::error::Error>> {
462 if let Some(ref r) = role {
468 validate_role(r)?;
469 let current = client
470 .get_acl(did)
471 .await
472 .ok()
473 .map(|e| e.role)
474 .unwrap_or_else(|| "<current-role>".to_string());
475 return Err(format!(
476 "role changes are not part of `acl update` — they need the compare-and-swap that \
477 `acl change-role` carries.\n\n Run: pnm acl change-role --did {did} --from \
478 {current} --to {r}"
479 )
480 .into());
481 }
482 let approve_scope_echo = approve_scope.clone();
483 let allowed_keys_echo = allowed_keys.clone();
484 let req = UpdateAclRequest {
485 label,
486 allowed_contexts: contexts,
487 step_up_approver: step_up_approver.clone(),
488 step_up_require: step_up_require.clone(),
489 approve_scope,
490 allowed_keys,
491 };
492 let entry = client.update_acl(did, req).await?;
493 println!("ACL entry updated:");
494 println!(" DID: {}", entry.did);
495 println!(
496 " Role: {}",
497 format_role(&entry.role, &entry.allowed_contexts)
498 );
499 if let Some(label) = &entry.label {
500 println!(" Label: {label}");
501 }
502 println!(
503 " Contexts: {}",
504 format_contexts(&entry.role, &entry.allowed_contexts)
505 );
506 if let Some(approver) = &step_up_approver {
507 if approver.is_empty() {
508 println!(" Step-up approver: (cleared)");
509 } else {
510 println!(" Step-up approver: {approver}");
511 }
512 }
513 if let Some(require) = &step_up_require {
514 if require.is_empty() {
515 println!(" Step-up require: (cleared)");
516 } else {
517 println!(" Step-up require: {require}");
518 }
519 }
520 if let Some(replacement) = &allowed_keys_echo {
524 let rendered = match replacement.as_deref() {
525 None => "(cleared — every key within the entry's contexts)".to_string(),
526 Some([]) => "(none — may invoke the signing oracle on no keys)".to_string(),
527 Some(keys) => format!("keys [{}]", keys.join(", ")),
528 };
529 println!(" Allowed keys: {rendered}");
530 }
531 if let Some(scope) = &approve_scope_echo {
534 let rendered = match scope {
535 ApproveScope::None => "(revoked — confers nothing)".to_string(),
536 ApproveScope::All => "all contexts".to_string(),
537 ApproveScope::Contexts(cs) => format!("contexts [{}]", cs.join(", ")),
538 };
539 println!(" Approve: {rendered}");
540 }
541 Ok(())
542}
543
544pub async fn cmd_acl_delete(
545 client: &VtaClient,
546 did: &str,
547) -> Result<(), Box<dyn std::error::Error>> {
548 client.delete_acl(did).await?;
549 println!("ACL entry deleted: {did}");
550 Ok(())
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556
557 #[test]
563 fn test_format_contexts_empty_is_role_dependent() {
564 assert_eq!(format_contexts("admin", &[]), "(unrestricted)");
565 for role in ["reader", "initiator", "application"] {
566 assert_eq!(
567 format_contexts(role, &[]),
568 "(none — acts nowhere)",
569 "empty contexts must not read as unrestricted for role {role}"
570 );
571 }
572 }
573
574 #[test]
578 fn test_least_privilege_approver_does_not_read_as_unrestricted() {
579 let contexts: Vec<String> = vec![];
580 assert_eq!(
581 format_contexts("reader", &contexts),
582 "(none — acts nowhere)"
583 );
584 assert_eq!(format_role("reader", &contexts), "reader");
585 assert_eq!(
586 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
587 Some("contexts [openvtc]")
588 );
589 }
590
591 #[test]
592 fn test_format_approve_scope() {
593 assert_eq!(
594 format_approve_scope(true, &[]).as_deref(),
595 Some("all contexts")
596 );
597 assert_eq!(
598 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
599 Some("contexts [openvtc]")
600 );
601 assert_eq!(
602 format_approve_scope(false, &["a".to_string(), "b".to_string()]).as_deref(),
603 Some("contexts [a, b]")
604 );
605 assert_eq!(format_approve_scope(false, &[]), None);
607 }
608
609 #[test]
610 fn test_format_contexts_single() {
611 let ctx = vec!["vta".to_string()];
612 assert_eq!(format_contexts("reader", &ctx), "vta");
613 }
614
615 #[test]
616 fn test_format_contexts_multiple() {
617 let ctx = vec!["vta".to_string(), "payments".to_string()];
618 assert_eq!(format_contexts("reader", &ctx), "vta, payments");
619 }
620
621 #[test]
626 fn test_format_allowed_keys_distinguishes_absent_from_empty() {
627 assert_eq!(format_allowed_keys(None), None, "no filter → no line");
628 assert_eq!(
629 format_allowed_keys(Some(&[])).as_deref(),
630 Some("(none — may invoke the signing oracle on no keys)")
631 );
632 assert_eq!(
633 format_allowed_keys(Some(&["k1".to_string(), "k2".to_string()])).as_deref(),
634 Some("keys [k1, k2]")
635 );
636 }
637
638 #[test]
639 fn test_allowed_keys_from_flags() {
640 assert_eq!(allowed_keys_from_flags(None, false), None);
642 assert_eq!(
644 allowed_keys_from_flags(Some(vec!["k1".into()]), false),
645 Some(Some(vec!["k1".to_string()]))
646 );
647 assert_eq!(allowed_keys_from_flags(None, true), Some(None));
650 }
651
652 #[test]
655 fn test_format_role_admin_no_contexts_is_super_admin() {
656 assert_eq!(format_role("admin", &[]), "super admin");
657 }
658
659 #[test]
660 fn test_format_role_admin_with_contexts_stays_admin() {
661 let ctx = vec!["vta".to_string()];
662 assert_eq!(format_role("admin", &ctx), "admin");
663 }
664
665 #[test]
666 fn test_format_role_initiator_unchanged() {
667 assert_eq!(format_role("initiator", &[]), "initiator");
668 }
669
670 #[test]
671 fn test_format_role_application_unchanged() {
672 let ctx = vec!["app".to_string()];
673 assert_eq!(format_role("application", &ctx), "application");
674 }
675
676 #[test]
679 fn test_validate_role_admin_ok() {
680 assert!(validate_role("admin").is_ok());
681 }
682
683 #[test]
684 fn test_validate_role_initiator_ok() {
685 assert!(validate_role("initiator").is_ok());
686 }
687
688 #[test]
689 fn test_validate_role_application_ok() {
690 assert!(validate_role("application").is_ok());
691 }
692
693 #[test]
694 fn test_validate_role_reader_ok() {
695 assert!(validate_role("reader").is_ok());
696 }
697
698 #[test]
699 fn test_validate_role_unknown_fails() {
700 let err = validate_role("superuser").unwrap_err();
701 assert!(err.to_string().contains("invalid role 'superuser'"));
702 }
703
704 #[test]
705 fn test_validate_role_empty_fails() {
706 assert!(validate_role("").is_err());
707 }
708}