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