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 =
161                format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts());
162
163            // Name + full DID. Full display exists so an operator can copy a
164            // complete identifier, so the DID is never abbreviated here.
165            let mut fields = full_display_pairs(&book, &entry.did);
166            fields.push(("Role", role));
167            // The raw label is normally what the Name line already shows; keep
168            // it only when something higher-ranked (an agent name) displaced it.
169            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    // Only give up a column to names if at least one entry has one — on a VTA
185    // where nothing has been labelled, a column of dashes is worse than no
186    // column.
187    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    // DIDs are abbreviated by `shorten_did` (SCID squeezed, domain tail kept),
219    // which frees the width the name column needs. `--full-display` and
220    // `--json` still carry every DID in full.
221    let mut constraints = vec![
222        Constraint::Min(34),    // DID
223        Constraint::Length(12), // Role
224        Constraint::Length(24), // Contexts
225        Constraint::Min(30),    // Created By
226    ];
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    // Name above DID, DID in full — a single-entry view is where an operator
254    // copies an identifier from.
255    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    // Normally the Name line above; shown separately only when something
267    // higher-ranked (a verified agent name) displaced the operator's label.
268    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
354/// Resolve the three mutually-exclusive approve flags into the wire value.
355///
356/// `None` means "leave unchanged" — which is why revoking needs its own flag
357/// rather than an empty `--approve-contexts`: an empty list cannot mean both
358/// "confer nothing" and "don't touch it".
359pub 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    // Echo the scope only when this call set it, so "unchanged" is visibly
425    // different from "set to confer nothing".
426    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    // ── format_contexts ────────────────────────────────────────────
451
452    /// Empty means "every context" only for an admin. This test previously
453    /// asserted `(unrestricted)` for an empty list regardless of role, which
454    /// pinned the bug rather than the behaviour.
455    #[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    /// The shape the `--approve-all` help text itself recommends: a reader
468    /// with no contexts whose authority is entirely `approve_scope`. It acts
469    /// nowhere, and the display must not suggest otherwise.
470    #[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        // Confers nothing ⇒ no line.
499        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    // ── format_role ────────────────────────────────────────────────
515
516    #[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    // ── validate_role ──────────────────────────────────────────────
539
540    #[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}