1use ratatui::{
2 layout::Constraint,
3 style::{Color, Modifier, Style},
4 widgets::{Block, Cell, Row, Table},
5};
6use vta_sdk::acl::ApproveScope;
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 async fn cmd_acl_list(
71 client: &VtaClient,
72 context: Option<&str>,
73) -> Result<(), Box<dyn std::error::Error>> {
74 let resp = client.list_acl(context).await?;
75
76 if crate::render::is_json_output() {
81 crate::render::print_json(&resp.entries)?;
82 return Ok(());
83 }
84
85 if resp.entries.is_empty() {
86 println!("No ACL entries found.");
87 return Ok(());
88 }
89
90 let mut book = NameBook::new();
94 book_from_acl(&mut book, &resp.entries);
95 resolve_agent_names_into(
98 &mut book,
99 resp.entries
100 .iter()
101 .flat_map(|e| [e.did.as_str(), e.created_by.as_str()]),
102 )
103 .await;
104
105 if is_full_display() {
106 print_full_list_title("ACL Entries", resp.entries.len());
107 for entry in &resp.entries {
108 let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
109 let role = format_role(&entry.role, &entry.allowed_contexts);
110 let approve = format_approve_scope(entry.approve_all_contexts, &entry.approve_contexts);
111
112 let mut fields = full_display_pairs(&book, &entry.did);
115 fields.push(("Role", role));
116 if let Some(label) = entry.label.as_deref()
119 && book.name_of(&entry.did).as_deref() != Some(label)
120 {
121 fields.push(("Label", label.to_string()));
122 }
123 fields.push(("Contexts", contexts));
124 if let Some(a) = approve {
125 fields.push(("Approve", a));
126 }
127 fields.push(("Created By", book.render_inline(&entry.created_by)));
128 print_full_entry_owned(&fields);
129 }
130 return Ok(());
131 }
132
133 let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
137
138 let header_style = Style::default()
139 .fg(Color::White)
140 .add_modifier(Modifier::BOLD);
141 let mut header_cells = vec!["DID", "Role", "Contexts", "Created By"];
142 if show_names {
143 header_cells.insert(0, NAME_HEADER);
144 }
145 let header = Row::new(header_cells).style(header_style).bottom_margin(1);
146
147 let rows: Vec<Row> = resp
148 .entries
149 .iter()
150 .map(|entry| {
151 let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
152 let mut cells = vec![
153 did_cell(&entry.did),
154 Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
155 Cell::from(contexts),
156 named_did_cell(&book, &entry.created_by),
157 ];
158 if show_names {
159 cells.insert(0, name_cell(&book, &entry.did));
160 }
161 Row::new(cells)
162 })
163 .collect();
164
165 let title = format!(" ACL Entries ({}) ", resp.entries.len());
166
167 let mut constraints = vec![
171 Constraint::Min(34), Constraint::Length(12), Constraint::Length(24), Constraint::Min(30), ];
176 if show_names {
177 constraints.insert(0, Constraint::Min(16));
178 }
179
180 let table = Table::new(rows, constraints)
181 .header(header)
182 .column_spacing(2)
183 .block(
184 Block::bordered()
185 .title(title)
186 .border_style(Style::default().fg(Color::DarkGray)),
187 );
188
189 let height = resp.entries.len() as u16 + 4;
190 print_widget(table, height);
191
192 Ok(())
193}
194
195pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
196 let entry = client.get_acl(did).await?;
197
198 let mut book = NameBook::new();
199 book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
200 resolve_agent_names_into(&mut book, [entry.did.as_str()]).await;
201
202 match book.name_of(&entry.did) {
205 Some(name) => {
206 println!("Name: {name}");
207 println!("DID: {}", entry.did);
208 }
209 None => println!("DID: {}", entry.did),
210 }
211 println!(
212 "Role: {}",
213 format_role(&entry.role, &entry.allowed_contexts)
214 );
215 if let Some(label) = entry.label.as_deref()
218 && book.name_of(&entry.did).as_deref() != Some(label)
219 {
220 println!("Label: {label}");
221 }
222 println!(
223 "Contexts: {}",
224 format_contexts(&entry.role, &entry.allowed_contexts)
225 );
226 if let Some(scope) = format_approve_scope(entry.approve_all_contexts, &entry.approve_contexts) {
227 println!("Approve: {scope}");
228 }
229 println!("Created At: {}", entry.created_at);
230 println!("Created By: {}", entry.created_by);
231 Ok(())
232}
233
234pub async fn cmd_acl_create(
235 client: &VtaClient,
236 did: String,
237 role: String,
238 label: Option<String>,
239 contexts: Vec<String>,
240 expires_at: Option<u64>,
241 step_up_approver: Option<String>,
242 step_up_require: Option<String>,
243 approve_all: bool,
244 approve_contexts: Vec<String>,
245) -> Result<(), Box<dyn std::error::Error>> {
246 validate_role(&role)?;
247 let mut req = CreateAclRequest::new(did, role).contexts(contexts);
248 if let Some(l) = label {
249 req = req.label(l);
250 }
251 if let Some(secs) = expires_at {
252 req = req.expires_at(secs);
253 }
254 if let Some(ref approver) = step_up_approver {
255 req = req.step_up_approver(approver.clone());
256 }
257 if let Some(ref require) = step_up_require {
258 req = req.step_up_require(require.clone());
259 }
260 if approve_all {
261 req = req.approve_all();
262 } else if !approve_contexts.is_empty() {
263 req = req.approve_contexts(approve_contexts);
264 }
265 let entry = client.create_acl(req).await?;
266 println!("ACL entry created:");
267 println!(" DID: {}", entry.did);
268 println!(
269 " Role: {}",
270 format_role(&entry.role, &entry.allowed_contexts)
271 );
272 if let Some(label) = &entry.label {
273 println!(" Label: {label}");
274 }
275 println!(
276 " Contexts: {}",
277 format_contexts(&entry.role, &entry.allowed_contexts)
278 );
279 if let Some(scope) = format_approve_scope(entry.approve_all_contexts, &entry.approve_contexts) {
280 println!(" Approve: {scope}");
281 }
282 if let Some(approver) = &step_up_approver {
283 println!(" Step-up approver: {approver}");
284 }
285 if let Some(require) = &step_up_require {
286 println!(" Step-up require: {require}");
287 }
288 match entry.expires_at {
289 Some(secs) => println!(
290 " Expires at: {} ({})",
291 crate::duration::format_local_time(secs),
292 crate::duration::format_remaining(secs),
293 ),
294 None => println!(" Expires at: (permanent)"),
295 }
296 Ok(())
297}
298
299pub fn approve_scope_from_flags(
305 approve_all: bool,
306 approve_contexts: Option<Vec<String>>,
307 approve_none: bool,
308) -> Option<ApproveScope> {
309 if approve_none {
310 Some(ApproveScope::None)
311 } else if approve_all {
312 Some(ApproveScope::All)
313 } else {
314 approve_contexts.map(ApproveScope::Contexts)
315 }
316}
317
318#[allow(clippy::too_many_arguments)]
319pub async fn cmd_acl_update(
320 client: &VtaClient,
321 did: &str,
322 role: Option<String>,
323 label: Option<String>,
324 contexts: Option<Vec<String>>,
325 step_up_approver: Option<String>,
326 step_up_require: Option<String>,
327 approve_scope: Option<ApproveScope>,
328) -> Result<(), Box<dyn std::error::Error>> {
329 if let Some(ref r) = role {
330 validate_role(r)?;
331 }
332 let approve_scope_echo = approve_scope.clone();
333 let req = UpdateAclRequest {
334 role,
335 label,
336 allowed_contexts: contexts,
337 step_up_approver: step_up_approver.clone(),
338 step_up_require: step_up_require.clone(),
339 approve_scope,
340 };
341 let entry = client.update_acl(did, req).await?;
342 println!("ACL entry updated:");
343 println!(" DID: {}", entry.did);
344 println!(
345 " Role: {}",
346 format_role(&entry.role, &entry.allowed_contexts)
347 );
348 if let Some(label) = &entry.label {
349 println!(" Label: {label}");
350 }
351 println!(
352 " Contexts: {}",
353 format_contexts(&entry.role, &entry.allowed_contexts)
354 );
355 if let Some(approver) = &step_up_approver {
356 if approver.is_empty() {
357 println!(" Step-up approver: (cleared)");
358 } else {
359 println!(" Step-up approver: {approver}");
360 }
361 }
362 if let Some(require) = &step_up_require {
363 if require.is_empty() {
364 println!(" Step-up require: (cleared)");
365 } else {
366 println!(" Step-up require: {require}");
367 }
368 }
369 if let Some(scope) = &approve_scope_echo {
372 let rendered = match scope {
373 ApproveScope::None => "(revoked — confers nothing)".to_string(),
374 ApproveScope::All => "all contexts".to_string(),
375 ApproveScope::Contexts(cs) => format!("contexts [{}]", cs.join(", ")),
376 };
377 println!(" Approve: {rendered}");
378 }
379 Ok(())
380}
381
382pub async fn cmd_acl_delete(
383 client: &VtaClient,
384 did: &str,
385) -> Result<(), Box<dyn std::error::Error>> {
386 client.delete_acl(did).await?;
387 println!("ACL entry deleted: {did}");
388 Ok(())
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
401 fn test_format_contexts_empty_is_role_dependent() {
402 assert_eq!(format_contexts("admin", &[]), "(unrestricted)");
403 for role in ["reader", "initiator", "application"] {
404 assert_eq!(
405 format_contexts(role, &[]),
406 "(none — acts nowhere)",
407 "empty contexts must not read as unrestricted for role {role}"
408 );
409 }
410 }
411
412 #[test]
416 fn test_least_privilege_approver_does_not_read_as_unrestricted() {
417 let contexts: Vec<String> = vec![];
418 assert_eq!(
419 format_contexts("reader", &contexts),
420 "(none — acts nowhere)"
421 );
422 assert_eq!(format_role("reader", &contexts), "reader");
423 assert_eq!(
424 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
425 Some("contexts [openvtc]")
426 );
427 }
428
429 #[test]
430 fn test_format_approve_scope() {
431 assert_eq!(
432 format_approve_scope(true, &[]).as_deref(),
433 Some("all contexts")
434 );
435 assert_eq!(
436 format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
437 Some("contexts [openvtc]")
438 );
439 assert_eq!(
440 format_approve_scope(false, &["a".to_string(), "b".to_string()]).as_deref(),
441 Some("contexts [a, b]")
442 );
443 assert_eq!(format_approve_scope(false, &[]), None);
445 }
446
447 #[test]
448 fn test_format_contexts_single() {
449 let ctx = vec!["vta".to_string()];
450 assert_eq!(format_contexts("reader", &ctx), "vta");
451 }
452
453 #[test]
454 fn test_format_contexts_multiple() {
455 let ctx = vec!["vta".to_string(), "payments".to_string()];
456 assert_eq!(format_contexts("reader", &ctx), "vta, payments");
457 }
458
459 #[test]
462 fn test_format_role_admin_no_contexts_is_super_admin() {
463 assert_eq!(format_role("admin", &[]), "super admin");
464 }
465
466 #[test]
467 fn test_format_role_admin_with_contexts_stays_admin() {
468 let ctx = vec!["vta".to_string()];
469 assert_eq!(format_role("admin", &ctx), "admin");
470 }
471
472 #[test]
473 fn test_format_role_initiator_unchanged() {
474 assert_eq!(format_role("initiator", &[]), "initiator");
475 }
476
477 #[test]
478 fn test_format_role_application_unchanged() {
479 let ctx = vec!["app".to_string()];
480 assert_eq!(format_role("application", &ctx), "application");
481 }
482
483 #[test]
486 fn test_validate_role_admin_ok() {
487 assert!(validate_role("admin").is_ok());
488 }
489
490 #[test]
491 fn test_validate_role_initiator_ok() {
492 assert!(validate_role("initiator").is_ok());
493 }
494
495 #[test]
496 fn test_validate_role_application_ok() {
497 assert!(validate_role("application").is_ok());
498 }
499
500 #[test]
501 fn test_validate_role_reader_ok() {
502 assert!(validate_role("reader").is_ok());
503 }
504
505 #[test]
506 fn test_validate_role_unknown_fails() {
507 let err = validate_role("superuser").unwrap_err();
508 assert!(err.to_string().contains("invalid role 'superuser'"));
509 }
510
511 #[test]
512 fn test_validate_role_empty_fails() {
513 assert!(validate_role("").is_err());
514 }
515}