Skip to main content

vta_cli_common/commands/
acl.rs

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
16/// Human-readable context list — **role-aware**, because an empty
17/// `allowed_contexts` means opposite things depending on the role.
18///
19/// `AuthClaims::is_super_admin` requires `Role::Admin` *and* an empty list;
20/// `has_context_access` otherwise iterates `allowed_contexts`, and an empty
21/// list matches nothing. So empty means "every context" for an admin and
22/// "no context at all" for every other role.
23///
24/// Rendering both as `(unrestricted)` misled in both directions on a
25/// security-relevant display: a correctly-scoped least-privilege approver
26/// looked like a blanket grant, and an operator auditing for over-broad
27/// access saw `(unrestricted)` on rows that were in fact inert.
28pub fn format_contexts(role: &str, contexts: &[String]) -> String {
29    // The wire form carries the role as a string, so parse it back before
30    // decoding. An unrecognised role falls to the most restrictive reading:
31    // a display must never invent authority it cannot confirm.
32    //
33    // `format_role` already renders an unrestricted admin as "super admin", so
34    // the two columns read together without repeating the term.
35    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
47/// Human-readable approve-authority — what this entry may *confer* via an
48/// approval (task-consent delegation / step-up ratification) while acting
49/// nowhere. `None` when it confers nothing, so callers omit the line entirely.
50pub 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    // `--json` short-circuits all rendering and emits a single JSON
77    // document. Empty result returns an empty array, NOT a printed
78    // "no entries" string — automation scripts depend on the JSON
79    // shape being consistent across populated and empty results.
80    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    // One pass over the entries names every subject from its label — and,
91    // for free, the `Created By` column too, since a granting admin nearly
92    // always holds an ACL entry of their own.
93    let mut book = NameBook::new();
94    book_from_acl(&mut book, &resp.entries);
95    // Opt-in, and only over the DIDs actually on screen — including the
96    // `created_by` column, which is where an unfamiliar DID most often shows up.
97    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            // Name + full DID. Full display exists so an operator can copy a
113            // complete identifier, so the DID is never abbreviated here.
114            let mut fields = full_display_pairs(&book, &entry.did);
115            fields.push(("Role", role));
116            // The raw label is normally what the Name line already shows; keep
117            // it only when something higher-ranked (an agent name) displaced it.
118            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    // Only give up a column to names if at least one entry has one — on a VTA
134    // where nothing has been labelled, a column of dashes is worse than no
135    // column.
136    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    // DIDs are abbreviated by `shorten_did` (SCID squeezed, domain tail kept),
168    // which frees the width the name column needs. `--full-display` and
169    // `--json` still carry every DID in full.
170    let mut constraints = vec![
171        Constraint::Min(34),    // DID
172        Constraint::Length(12), // Role
173        Constraint::Length(24), // Contexts
174        Constraint::Min(30),    // Created By
175    ];
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    // Name above DID, DID in full — a single-entry view is where an operator
203    // copies an identifier from.
204    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    // Normally the Name line above; shown separately only when something
216    // higher-ranked (a verified agent name) displaced the operator's label.
217    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
299/// Resolve the three mutually-exclusive approve flags into the wire value.
300///
301/// `None` means "leave unchanged" — which is why revoking needs its own flag
302/// rather than an empty `--approve-contexts`: an empty list cannot mean both
303/// "confer nothing" and "don't touch it".
304pub 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    // Echo the scope only when this call set it, so "unchanged" is visibly
370    // different from "set to confer nothing".
371    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    // ── format_contexts ────────────────────────────────────────────
396
397    /// Empty means "every context" only for an admin. This test previously
398    /// asserted `(unrestricted)` for an empty list regardless of role, which
399    /// pinned the bug rather than the behaviour.
400    #[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    /// The shape the `--approve-all` help text itself recommends: a reader
413    /// with no contexts whose authority is entirely `approve_scope`. It acts
414    /// nowhere, and the display must not suggest otherwise.
415    #[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        // Confers nothing ⇒ no line.
444        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    // ── format_role ────────────────────────────────────────────────
460
461    #[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    // ── validate_role ──────────────────────────────────────────────
484
485    #[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}