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, 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
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
70/// Parse the operator's `--direction` value, defaulting to the historical
71/// "who may act in this context" reading and refusing anything else with the
72/// valid set — the same table the wire uses, so flag and payload cannot drift.
73pub 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
82/// The question a `--context` + `--direction` pair actually asked, in words.
83///
84/// On screen because the two directions are equally plausible readings of the
85/// same flag and produce different lists: a subtree sweep that quietly ran as
86/// an act-in query returns the ancestors it is not revoking and looks like a
87/// complete answer (#822).
88pub 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    // `--json` short-circuits all rendering and emits a single JSON
112    // document. Empty result returns an empty array, NOT a printed
113    // "no entries" string — automation scripts depend on the JSON
114    // shape being consistent across populated and empty results.
115    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        // An empty subtree sweep is the answer that most needs qualifying:
122        // it can equally mean "nothing is granted beneath this context" and
123        // "you asked the other direction". Name the question that was asked.
124        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    // One pass over the entries names every subject from its label — and,
132    // for free, the `Created By` column too, since a granting admin nearly
133    // always holds an ACL entry of their own.
134    let mut book = NameBook::new();
135    book_from_acl(&mut book, &resp.entries);
136    // Opt-in, and only over the DIDs actually on screen — including the
137    // `created_by` column, which is where an unfamiliar DID most often shows up.
138    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    // Name the question on screen whenever a context filter narrowed the
147    // answer. Two opposite readings of the same `--context` produce two
148    // legitimate, differently-shaped lists, and an operator who cannot see
149    // which one they got cannot tell a short list from a wrong one.
150    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            // Name + full DID. Full display exists so an operator can copy a
163            // complete identifier, so the DID is never abbreviated here.
164            let mut fields = full_display_pairs(&book, &entry.did);
165            fields.push(("Role", role));
166            // The raw label is normally what the Name line already shows; keep
167            // it only when something higher-ranked (an agent name) displaced it.
168            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    // Only give up a column to names if at least one entry has one — on a VTA
184    // where nothing has been labelled, a column of dashes is worse than no
185    // column.
186    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    // DIDs are abbreviated by `shorten_did` (SCID squeezed, domain tail kept),
218    // which frees the width the name column needs. `--full-display` and
219    // `--json` still carry every DID in full.
220    let mut constraints = vec![
221        Constraint::Min(34),    // DID
222        Constraint::Length(12), // Role
223        Constraint::Length(24), // Contexts
224        Constraint::Min(30),    // Created By
225    ];
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    // Name above DID, DID in full — a single-entry view is where an operator
253    // copies an identifier from.
254    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    // Normally the Name line above; shown separately only when something
266    // higher-ranked (a verified agent name) displaced the operator's label.
267    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
349/// Resolve the three mutually-exclusive approve flags into the wire value.
350///
351/// `None` means "leave unchanged" — which is why revoking needs its own flag
352/// rather than an empty `--approve-contexts`: an empty list cannot mean both
353/// "confer nothing" and "don't touch it".
354pub 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    // Echo the scope only when this call set it, so "unchanged" is visibly
420    // different from "set to confer nothing".
421    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    // ── format_contexts ────────────────────────────────────────────
446
447    /// Empty means "every context" only for an admin. This test previously
448    /// asserted `(unrestricted)` for an empty list regardless of role, which
449    /// pinned the bug rather than the behaviour.
450    #[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    /// The shape the `--approve-all` help text itself recommends: a reader
463    /// with no contexts whose authority is entirely `approve_scope`. It acts
464    /// nowhere, and the display must not suggest otherwise.
465    #[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        // Confers nothing ⇒ no line.
494        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    // ── format_role ────────────────────────────────────────────────
510
511    #[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    // ── validate_role ──────────────────────────────────────────────
534
535    #[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}