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 = format_approve_scope(entry.approve_all_contexts, &entry.approve_contexts);
161
162 let mut fields = full_display_pairs(&book, &entry.did);
165 fields.push(("Role", role));
166 if let Some(label) = entry.label.as_deref()
169 && book.name_of(&entry.did).as_deref() != Some(label)
170 {
171 fields.push(("Label", label.to_string()));
172 }
173 fields.push(("Contexts", contexts));
174 if let Some(a) = approve {
175 fields.push(("Approve", a));
176 }
177 fields.push(("Created By", book.render_inline(&entry.created_by)));
178 print_full_entry_owned(&fields);
179 }
180 return Ok(());
181 }
182
183 let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
187
188 let header_style = Style::default()
189 .fg(Color::White)
190 .add_modifier(Modifier::BOLD);
191 let mut header_cells = vec!["DID", "Role", "Contexts", "Created By"];
192 if show_names {
193 header_cells.insert(0, NAME_HEADER);
194 }
195 let header = Row::new(header_cells).style(header_style).bottom_margin(1);
196
197 let rows: Vec<Row> = resp
198 .entries
199 .iter()
200 .map(|entry| {
201 let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
202 let mut cells = vec![
203 did_cell(&entry.did),
204 Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
205 Cell::from(contexts),
206 named_did_cell(&book, &entry.created_by),
207 ];
208 if show_names {
209 cells.insert(0, name_cell(&book, &entry.did));
210 }
211 Row::new(cells)
212 })
213 .collect();
214
215 let title = format!(" {heading} ({}) ", resp.entries.len());
216
217 let mut constraints = vec![
221 Constraint::Min(34), Constraint::Length(12), Constraint::Length(24), Constraint::Min(30), ];
226 if show_names {
227 constraints.insert(0, Constraint::Min(16));
228 }
229
230 let table = Table::new(rows, constraints)
231 .header(header)
232 .column_spacing(2)
233 .block(
234 Block::bordered()
235 .title(title)
236 .border_style(Style::default().fg(Color::DarkGray)),
237 );
238
239 let height = resp.entries.len() as u16 + 4;
240 print_widget(table, height);
241
242 Ok(())
243}
244
245pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
246 let entry = client.get_acl(did).await?;
247
248 let mut book = NameBook::new();
249 book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
250 resolve_agent_names_into(&mut book, [entry.did.as_str()]).await;
251
252 match book.name_of(&entry.did) {
255 Some(name) => {
256 println!("Name: {name}");
257 println!("DID: {}", entry.did);
258 }
259 None => println!("DID: {}", entry.did),
260 }
261 println!(
262 "Role: {}",
263 format_role(&entry.role, &entry.allowed_contexts)
264 );
265 if let Some(label) = entry.label.as_deref()
268 && book.name_of(&entry.did).as_deref() != Some(label)
269 {
270 println!("Label: {label}");
271 }
272 println!(
273 "Contexts: {}",
274 format_contexts(&entry.role, &entry.allowed_contexts)
275 );
276 if let Some(scope) = format_approve_scope(entry.approve_all_contexts, &entry.approve_contexts) {
277 println!("Approve: {scope}");
278 }
279 println!("Created At: {}", entry.created_at);
280 println!("Created By: {}", entry.created_by);
281 Ok(())
282}
283
284pub async fn cmd_acl_create(
285 client: &VtaClient,
286 did: String,
287 role: String,
288 label: Option<String>,
289 contexts: Vec<String>,
290 expires_at: Option<u64>,
291 step_up_approver: Option<String>,
292 step_up_require: Option<String>,
293 approve_all: bool,
294 approve_contexts: Vec<String>,
295) -> Result<(), Box<dyn std::error::Error>> {
296 validate_role(&role)?;
297 let mut req = CreateAclRequest::new(did, role).contexts(contexts);
298 if let Some(l) = label {
299 req = req.label(l);
300 }
301 if let Some(secs) = expires_at {
302 req = req.expires_at(secs);
303 }
304 if let Some(ref approver) = step_up_approver {
305 req = req.step_up_approver(approver.clone());
306 }
307 if let Some(ref require) = step_up_require {
308 req = req.step_up_require(require.clone());
309 }
310 if approve_all {
311 req = req.approve_all();
312 } else if !approve_contexts.is_empty() {
313 req = req.approve_contexts(approve_contexts);
314 }
315 let entry = client.create_acl(req).await?;
316 println!("ACL entry created:");
317 println!(" DID: {}", entry.did);
318 println!(
319 " Role: {}",
320 format_role(&entry.role, &entry.allowed_contexts)
321 );
322 if let Some(label) = &entry.label {
323 println!(" Label: {label}");
324 }
325 println!(
326 " Contexts: {}",
327 format_contexts(&entry.role, &entry.allowed_contexts)
328 );
329 if let Some(scope) = format_approve_scope(entry.approve_all_contexts, &entry.approve_contexts) {
330 println!(" Approve: {scope}");
331 }
332 if let Some(approver) = &step_up_approver {
333 println!(" Step-up approver: {approver}");
334 }
335 if let Some(require) = &step_up_require {
336 println!(" Step-up require: {require}");
337 }
338 match entry.expires_at {
339 Some(secs) => println!(
340 " Expires at: {} ({})",
341 crate::duration::format_local_time(secs),
342 crate::duration::format_remaining(secs),
343 ),
344 None => println!(" Expires at: (permanent)"),
345 }
346 Ok(())
347}
348
349pub fn approve_scope_from_flags(
355 approve_all: bool,
356 approve_contexts: Option<Vec<String>>,
357 approve_none: bool,
358) -> Option<ApproveScope> {
359 if approve_none {
360 Some(ApproveScope::None)
361 } else if approve_all {
362 Some(ApproveScope::All)
363 } else {
364 approve_contexts.map(ApproveScope::Contexts)
365 }
366}
367
368#[allow(clippy::too_many_arguments)]
369pub async fn cmd_acl_update(
370 client: &VtaClient,
371 did: &str,
372 role: Option<String>,
373 label: Option<String>,
374 contexts: Option<Vec<String>>,
375 step_up_approver: Option<String>,
376 step_up_require: Option<String>,
377 approve_scope: Option<ApproveScope>,
378) -> Result<(), Box<dyn std::error::Error>> {
379 if let Some(ref r) = role {
380 validate_role(r)?;
381 }
382 let approve_scope_echo = approve_scope.clone();
383 let req = UpdateAclRequest {
384 role,
385 label,
386 allowed_contexts: contexts,
387 step_up_approver: step_up_approver.clone(),
388 step_up_require: step_up_require.clone(),
389 approve_scope,
390 };
391 let entry = client.update_acl(did, req).await?;
392 println!("ACL entry updated:");
393 println!(" DID: {}", entry.did);
394 println!(
395 " Role: {}",
396 format_role(&entry.role, &entry.allowed_contexts)
397 );
398 if let Some(label) = &entry.label {
399 println!(" Label: {label}");
400 }
401 println!(
402 " Contexts: {}",
403 format_contexts(&entry.role, &entry.allowed_contexts)
404 );
405 if let Some(approver) = &step_up_approver {
406 if approver.is_empty() {
407 println!(" Step-up approver: (cleared)");
408 } else {
409 println!(" Step-up approver: {approver}");
410 }
411 }
412 if let Some(require) = &step_up_require {
413 if require.is_empty() {
414 println!(" Step-up require: (cleared)");
415 } else {
416 println!(" Step-up require: {require}");
417 }
418 }
419 if let Some(scope) = &approve_scope_echo {
422 let rendered = match scope {
423 ApproveScope::None => "(revoked — confers nothing)".to_string(),
424 ApproveScope::All => "all contexts".to_string(),
425 ApproveScope::Contexts(cs) => format!("contexts [{}]", cs.join(", ")),
426 };
427 println!(" Approve: {rendered}");
428 }
429 Ok(())
430}
431
432pub async fn cmd_acl_delete(
433 client: &VtaClient,
434 did: &str,
435) -> Result<(), Box<dyn std::error::Error>> {
436 client.delete_acl(did).await?;
437 println!("ACL entry deleted: {did}");
438 Ok(())
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
451 fn test_format_contexts_empty_is_role_dependent() {
452 assert_eq!(format_contexts("admin", &[]), "(unrestricted)");
453 for role in ["reader", "initiator", "application"] {
454 assert_eq!(
455 format_contexts(role, &[]),
456 "(none — acts nowhere)",
457 "empty contexts must not read as unrestricted for role {role}"
458 );
459 }
460 }
461
462 #[test]
466 fn test_least_privilege_approver_does_not_read_as_unrestricted() {
467 let contexts: Vec<String> = vec![];
468 assert_eq!(
469 format_contexts("reader", &contexts),
470 "(none — acts nowhere)"
471 );
472 assert_eq!(format_role("reader", &contexts), "reader");
473 assert_eq!(
474 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
475 Some("contexts [openvtc]")
476 );
477 }
478
479 #[test]
480 fn test_format_approve_scope() {
481 assert_eq!(
482 format_approve_scope(true, &[]).as_deref(),
483 Some("all contexts")
484 );
485 assert_eq!(
486 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
487 Some("contexts [openvtc]")
488 );
489 assert_eq!(
490 format_approve_scope(false, &["a".to_string(), "b".to_string()]).as_deref(),
491 Some("contexts [a, b]")
492 );
493 assert_eq!(format_approve_scope(false, &[]), None);
495 }
496
497 #[test]
498 fn test_format_contexts_single() {
499 let ctx = vec!["vta".to_string()];
500 assert_eq!(format_contexts("reader", &ctx), "vta");
501 }
502
503 #[test]
504 fn test_format_contexts_multiple() {
505 let ctx = vec!["vta".to_string(), "payments".to_string()];
506 assert_eq!(format_contexts("reader", &ctx), "vta, payments");
507 }
508
509 #[test]
512 fn test_format_role_admin_no_contexts_is_super_admin() {
513 assert_eq!(format_role("admin", &[]), "super admin");
514 }
515
516 #[test]
517 fn test_format_role_admin_with_contexts_stays_admin() {
518 let ctx = vec!["vta".to_string()];
519 assert_eq!(format_role("admin", &ctx), "admin");
520 }
521
522 #[test]
523 fn test_format_role_initiator_unchanged() {
524 assert_eq!(format_role("initiator", &[]), "initiator");
525 }
526
527 #[test]
528 fn test_format_role_application_unchanged() {
529 let ctx = vec!["app".to_string()];
530 assert_eq!(format_role("application", &ctx), "application");
531 }
532
533 #[test]
536 fn test_validate_role_admin_ok() {
537 assert!(validate_role("admin").is_ok());
538 }
539
540 #[test]
541 fn test_validate_role_initiator_ok() {
542 assert!(validate_role("initiator").is_ok());
543 }
544
545 #[test]
546 fn test_validate_role_application_ok() {
547 assert!(validate_role("application").is_ok());
548 }
549
550 #[test]
551 fn test_validate_role_reader_ok() {
552 assert!(validate_role("reader").is_ok());
553 }
554
555 #[test]
556 fn test_validate_role_unknown_fails() {
557 let err = validate_role("superuser").unwrap_err();
558 assert!(err.to_string().contains("invalid role 'superuser'"));
559 }
560
561 #[test]
562 fn test_validate_role_empty_fails() {
563 assert!(validate_role("").is_err());
564 }
565}