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::client::ChangeAclRoleRequest;
8use vta_sdk::prelude::*;
9use vti_common::acl::{Role, act_scope_for};
10
11use crate::display::{
12    NAME_HEADER, NameBook, NameSource, book_from_acl, did_cell, full_display_pairs, name_cell,
13    named_did_cell, resolve_agent_names_into,
14};
15use crate::render::{is_full_display, print_full_entry_owned, print_full_list_title, print_widget};
16
17/// Human-readable context list — **role-aware**, because an empty
18/// `allowed_contexts` means opposite things depending on the role.
19///
20/// `AuthClaims::is_super_admin` requires `Role::Admin` *and* an empty list;
21/// `has_context_access` otherwise iterates `allowed_contexts`, and an empty
22/// list matches nothing. So empty means "every context" for an admin and
23/// "no context at all" for every other role.
24///
25/// Rendering both as `(unrestricted)` misled in both directions on a
26/// security-relevant display: a correctly-scoped least-privilege approver
27/// looked like a blanket grant, and an operator auditing for over-broad
28/// access saw `(unrestricted)` on rows that were in fact inert.
29pub fn format_contexts(role: &str, contexts: &[String]) -> String {
30    // The wire form carries the role as a string, so parse it back before
31    // decoding. An unrecognised role falls to the most restrictive reading:
32    // a display must never invent authority it cannot confirm.
33    //
34    // `format_role` already renders an unrestricted admin as "super admin", so
35    // the two columns read together without repeating the term.
36    let role = Role::parse(role).unwrap_or(Role::Monitor);
37    act_scope_for(&role, contexts).to_string()
38}
39
40pub fn format_role(role: &str, contexts: &[String]) -> String {
41    if role == "admin" && contexts.is_empty() {
42        "super admin".to_string()
43    } else {
44        role.to_string()
45    }
46}
47
48/// Human-readable approve-authority — what this entry may *confer* via an
49/// approval (task-consent delegation / step-up ratification) while acting
50/// nowhere. `None` when it confers nothing, so callers omit the line entirely.
51pub fn format_approve_scope(approve_all: bool, approve_contexts: &[String]) -> Option<String> {
52    if approve_all {
53        Some("all contexts".to_string())
54    } else if !approve_contexts.is_empty() {
55        Some(format!("contexts [{}]", approve_contexts.join(", ")))
56    } else {
57        None
58    }
59}
60
61pub fn validate_role(role: &str) -> Result<(), Box<dyn std::error::Error>> {
62    match role {
63        "admin" | "initiator" | "application" | "reader" => Ok(()),
64        _ => Err(format!(
65            "invalid role '{role}', expected: admin, initiator, application, or reader"
66        )
67        .into()),
68    }
69}
70
71/// Parse the operator's `--direction` value, defaulting to the historical
72/// "who may act in this context" reading and refusing anything else with the
73/// valid set — the same table the wire uses, so flag and payload cannot drift.
74pub fn parse_direction(
75    direction: Option<&str>,
76) -> Result<ContextDirection, Box<dyn std::error::Error>> {
77    match direction {
78        None => Ok(ContextDirection::default()),
79        Some(d) => Ok(d.parse::<ContextDirection>()?),
80    }
81}
82
83/// The question a `--context` + `--direction` pair actually asked, in words.
84///
85/// On screen because the two directions are equally plausible readings of the
86/// same flag and produce different lists: a subtree sweep that quietly ran as
87/// an act-in query returns the ancestors it is not revoking and looks like a
88/// complete answer (#822).
89pub fn describe_filter(context: &str, direction: ContextDirection) -> String {
90    match direction {
91        ContextDirection::ActingIn => format!("able to act in {context}"),
92        ContextDirection::Subtree => format!("granted at or beneath {context}"),
93        ContextDirection::Any => format!("with authority touching {context}"),
94    }
95}
96
97pub async fn cmd_acl_list(
98    client: &VtaClient,
99    context: Option<&str>,
100    direction: Option<&str>,
101) -> Result<(), Box<dyn std::error::Error>> {
102    let direction = parse_direction(direction)?;
103    if context.is_none() && direction != ContextDirection::default() {
104        return Err(format!(
105            "--direction {direction} says how to read --context, and no --context was given.\n\
106             Try: pnm acl list --context <id> --direction {direction}"
107        )
108        .into());
109    }
110    let resp = client.list_acl_in_direction(context, direction).await?;
111
112    // `--json` short-circuits all rendering and emits a single JSON
113    // document. Empty result returns an empty array, NOT a printed
114    // "no entries" string — automation scripts depend on the JSON
115    // shape being consistent across populated and empty results.
116    if crate::render::is_json_output() {
117        crate::render::print_json(&resp.entries)?;
118        return Ok(());
119    }
120
121    if resp.entries.is_empty() {
122        // An empty subtree sweep is the answer that most needs qualifying:
123        // it can equally mean "nothing is granted beneath this context" and
124        // "you asked the other direction". Name the question that was asked.
125        match context {
126            Some(ctx) => println!("No ACL entries found {}.", describe_filter(ctx, direction)),
127            None => println!("No ACL entries found."),
128        }
129        return Ok(());
130    }
131
132    // One pass over the entries names every subject from its label — and,
133    // for free, the `Created By` column too, since a granting admin nearly
134    // always holds an ACL entry of their own.
135    let mut book = NameBook::new();
136    book_from_acl(&mut book, &resp.entries);
137    // Opt-in, and only over the DIDs actually on screen — including the
138    // `created_by` column, which is where an unfamiliar DID most often shows up.
139    resolve_agent_names_into(
140        &mut book,
141        resp.entries
142            .iter()
143            .flat_map(|e| [e.did.as_str(), e.created_by.as_str()]),
144    )
145    .await;
146
147    // Name the question on screen whenever a context filter narrowed the
148    // answer. Two opposite readings of the same `--context` produce two
149    // legitimate, differently-shaped lists, and an operator who cannot see
150    // which one they got cannot tell a short list from a wrong one.
151    let heading = match context {
152        Some(ctx) => format!("ACL Entries — {}", describe_filter(ctx, direction)),
153        None => "ACL Entries".to_string(),
154    };
155
156    if is_full_display() {
157        print_full_list_title(&heading, resp.entries.len());
158        for entry in &resp.entries {
159            let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
160            let role = format_role(&entry.role, &entry.allowed_contexts);
161            let approve =
162                format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts());
163
164            // Name + full DID. Full display exists so an operator can copy a
165            // complete identifier, so the DID is never abbreviated here.
166            let mut fields = full_display_pairs(&book, &entry.did);
167            fields.push(("Role", role));
168            // The raw label is normally what the Name line already shows; keep
169            // it only when something higher-ranked (an agent name) displaced it.
170            if let Some(label) = entry.label.as_deref()
171                && book.name_of(&entry.did).as_deref() != Some(label)
172            {
173                fields.push(("Label", label.to_string()));
174            }
175            fields.push(("Contexts", contexts));
176            if let Some(a) = approve {
177                fields.push(("Approve", a));
178            }
179            fields.push(("Created By", book.render_inline(&entry.created_by)));
180            print_full_entry_owned(&fields);
181        }
182        return Ok(());
183    }
184
185    // Only give up a column to names if at least one entry has one — on a VTA
186    // where nothing has been labelled, a column of dashes is worse than no
187    // column.
188    let show_names = book.names_any(resp.entries.iter().map(|e| e.did.as_str()));
189
190    let header_style = Style::default()
191        .fg(Color::White)
192        .add_modifier(Modifier::BOLD);
193    let mut header_cells = vec!["DID", "Role", "Contexts", "Created By"];
194    if show_names {
195        header_cells.insert(0, NAME_HEADER);
196    }
197    let header = Row::new(header_cells).style(header_style).bottom_margin(1);
198
199    let rows: Vec<Row> = resp
200        .entries
201        .iter()
202        .map(|entry| {
203            let contexts = format_contexts(&entry.role, &entry.allowed_contexts);
204            let mut cells = vec![
205                did_cell(&entry.did),
206                Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
207                Cell::from(contexts),
208                named_did_cell(&book, &entry.created_by),
209            ];
210            if show_names {
211                cells.insert(0, name_cell(&book, &entry.did));
212            }
213            Row::new(cells)
214        })
215        .collect();
216
217    let title = format!(" {heading} ({}) ", resp.entries.len());
218
219    // DIDs are abbreviated by `shorten_did` (SCID squeezed, domain tail kept),
220    // which frees the width the name column needs. `--full-display` and
221    // `--json` still carry every DID in full.
222    let mut constraints = vec![
223        Constraint::Min(34),    // DID
224        Constraint::Length(12), // Role
225        Constraint::Length(24), // Contexts
226        Constraint::Min(30),    // Created By
227    ];
228    if show_names {
229        constraints.insert(0, Constraint::Min(16));
230    }
231
232    let table = Table::new(rows, constraints)
233        .header(header)
234        .column_spacing(2)
235        .block(
236            Block::bordered()
237                .title(title)
238                .border_style(Style::default().fg(Color::DarkGray)),
239        );
240
241    let height = resp.entries.len() as u16 + 4;
242    print_widget(table, height);
243
244    Ok(())
245}
246
247pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
248    let entry = client.get_acl(did).await?;
249
250    let mut book = NameBook::new();
251    book.insert_opt(&entry.did, entry.label.as_deref(), NameSource::AclLabel);
252    resolve_agent_names_into(&mut book, [entry.did.as_str()]).await;
253
254    // Name above DID, DID in full — a single-entry view is where an operator
255    // copies an identifier from.
256    match book.name_of(&entry.did) {
257        Some(name) => {
258            println!("Name:             {name}");
259            println!("DID:              {}", entry.did);
260        }
261        None => println!("DID:              {}", entry.did),
262    }
263    println!(
264        "Role:             {}",
265        format_role(&entry.role, &entry.allowed_contexts)
266    );
267    // Normally the Name line above; shown separately only when something
268    // higher-ranked (a verified agent name) displaced the operator's label.
269    if let Some(label) = entry.label.as_deref()
270        && book.name_of(&entry.did).as_deref() != Some(label)
271    {
272        println!("Label:            {label}");
273    }
274    println!(
275        "Contexts:         {}",
276        format_contexts(&entry.role, &entry.allowed_contexts)
277    );
278    if let Some(scope) =
279        format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
280    {
281        println!("Approve:          {scope}");
282    }
283    println!("Created At:       {}", entry.created_at);
284    println!("Created By:       {}", entry.created_by);
285    Ok(())
286}
287
288pub async fn cmd_acl_create(
289    client: &VtaClient,
290    did: String,
291    role: String,
292    label: Option<String>,
293    contexts: Vec<String>,
294    expires_at: Option<u64>,
295    step_up_approver: Option<String>,
296    step_up_require: Option<String>,
297    approve_all: bool,
298    approve_contexts: Vec<String>,
299) -> Result<(), Box<dyn std::error::Error>> {
300    validate_role(&role)?;
301    let mut req = CreateAclRequest::new(did, role).contexts(contexts);
302    if let Some(l) = label {
303        req = req.label(l);
304    }
305    if let Some(secs) = expires_at {
306        req = req.expires_at(secs);
307    }
308    if let Some(ref approver) = step_up_approver {
309        req = req.step_up_approver(approver.clone());
310    }
311    if let Some(ref require) = step_up_require {
312        req = req.step_up_require(require.clone());
313    }
314    if approve_all {
315        req = req.approve_all();
316    } else if !approve_contexts.is_empty() {
317        req = req.approve_contexts(approve_contexts);
318    }
319    let entry = client.create_acl(req).await?;
320    println!("ACL entry created:");
321    println!("  DID:        {}", entry.did);
322    println!(
323        "  Role:       {}",
324        format_role(&entry.role, &entry.allowed_contexts)
325    );
326    if let Some(label) = &entry.label {
327        println!("  Label:      {label}");
328    }
329    println!(
330        "  Contexts:   {}",
331        format_contexts(&entry.role, &entry.allowed_contexts)
332    );
333    if let Some(scope) =
334        format_approve_scope(entry.approve_all_contexts(), entry.approve_contexts())
335    {
336        println!("  Approve:    {scope}");
337    }
338    if let Some(approver) = &step_up_approver {
339        println!("  Step-up approver: {approver}");
340    }
341    if let Some(require) = &step_up_require {
342        println!("  Step-up require:  {require}");
343    }
344    match entry.expires_at {
345        Some(secs) => println!(
346            "  Expires at: {} ({})",
347            crate::duration::format_local_time(secs),
348            crate::duration::format_remaining(secs),
349        ),
350        None => println!("  Expires at: (permanent)"),
351    }
352    Ok(())
353}
354
355/// Resolve the three mutually-exclusive approve flags into the wire value.
356///
357/// `None` means "leave unchanged" — which is why revoking needs its own flag
358/// rather than an empty `--approve-contexts`: an empty list cannot mean both
359/// "confer nothing" and "don't touch it".
360pub fn approve_scope_from_flags(
361    approve_all: bool,
362    approve_contexts: Option<Vec<String>>,
363    approve_none: bool,
364) -> Option<ApproveScope> {
365    if approve_none {
366        Some(ApproveScope::None)
367    } else if approve_all {
368        Some(ApproveScope::All)
369    } else {
370        approve_contexts.map(ApproveScope::Contexts)
371    }
372}
373
374/// `pnm acl change-role` — transition a subject's role with a
375/// compare-and-swap on the role they currently hold.
376pub async fn cmd_acl_change_role(
377    client: &VtaClient,
378    did: &str,
379    from_role: &str,
380    to_role: &str,
381    reason: Option<String>,
382) -> Result<(), Box<dyn std::error::Error>> {
383    validate_role(from_role)?;
384    validate_role(to_role)?;
385
386    let entry = client
387        .change_acl_role(
388            did,
389            ChangeAclRoleRequest {
390                from_role: from_role.to_string(),
391                to_role: to_role.to_string(),
392                reason,
393            },
394        )
395        .await?;
396
397    println!("ACL role changed:");
398    println!("  DID:  {}", entry.did);
399    println!(
400        "  Role: {} \u{2192} {}",
401        from_role,
402        format_role(&entry.role, &entry.allowed_contexts)
403    );
404    Ok(())
405}
406
407#[allow(clippy::too_many_arguments)]
408pub async fn cmd_acl_update(
409    client: &VtaClient,
410    did: &str,
411    role: Option<String>,
412    label: Option<String>,
413    contexts: Option<Vec<String>>,
414    step_up_approver: Option<String>,
415    step_up_require: Option<String>,
416    approve_scope: Option<ApproveScope>,
417) -> Result<(), Box<dyn std::error::Error>> {
418    // Role transitions moved to `acl change-role`, which carries the
419    // compare-and-swap that makes a concurrent edit an error instead of a
420    // silent overwrite. Rather than just refusing, look up the role the
421    // subject actually holds so the operator can copy the fixed command —
422    // `--from` is the one argument they cannot guess.
423    if let Some(ref r) = role {
424        validate_role(r)?;
425        let current = client
426            .get_acl(did)
427            .await
428            .ok()
429            .map(|e| e.role)
430            .unwrap_or_else(|| "<current-role>".to_string());
431        return Err(format!(
432            "role changes are not part of `acl update` — they need the compare-and-swap that \
433             `acl change-role` carries.\n\n  Run: pnm acl change-role --did {did} --from \
434             {current} --to {r}"
435        )
436        .into());
437    }
438    let approve_scope_echo = approve_scope.clone();
439    let req = UpdateAclRequest {
440        label,
441        allowed_contexts: contexts,
442        step_up_approver: step_up_approver.clone(),
443        step_up_require: step_up_require.clone(),
444        approve_scope,
445    };
446    let entry = client.update_acl(did, req).await?;
447    println!("ACL entry updated:");
448    println!("  DID:      {}", entry.did);
449    println!(
450        "  Role:     {}",
451        format_role(&entry.role, &entry.allowed_contexts)
452    );
453    if let Some(label) = &entry.label {
454        println!("  Label:    {label}");
455    }
456    println!(
457        "  Contexts: {}",
458        format_contexts(&entry.role, &entry.allowed_contexts)
459    );
460    if let Some(approver) = &step_up_approver {
461        if approver.is_empty() {
462            println!("  Step-up approver: (cleared)");
463        } else {
464            println!("  Step-up approver: {approver}");
465        }
466    }
467    if let Some(require) = &step_up_require {
468        if require.is_empty() {
469            println!("  Step-up require:  (cleared)");
470        } else {
471            println!("  Step-up require:  {require}");
472        }
473    }
474    // Echo the scope only when this call set it, so "unchanged" is visibly
475    // different from "set to confer nothing".
476    if let Some(scope) = &approve_scope_echo {
477        let rendered = match scope {
478            ApproveScope::None => "(revoked — confers nothing)".to_string(),
479            ApproveScope::All => "all contexts".to_string(),
480            ApproveScope::Contexts(cs) => format!("contexts [{}]", cs.join(", ")),
481        };
482        println!("  Approve:  {rendered}");
483    }
484    Ok(())
485}
486
487pub async fn cmd_acl_delete(
488    client: &VtaClient,
489    did: &str,
490) -> Result<(), Box<dyn std::error::Error>> {
491    client.delete_acl(did).await?;
492    println!("ACL entry deleted: {did}");
493    Ok(())
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    // ── format_contexts ────────────────────────────────────────────
501
502    /// Empty means "every context" only for an admin. This test previously
503    /// asserted `(unrestricted)` for an empty list regardless of role, which
504    /// pinned the bug rather than the behaviour.
505    #[test]
506    fn test_format_contexts_empty_is_role_dependent() {
507        assert_eq!(format_contexts("admin", &[]), "(unrestricted)");
508        for role in ["reader", "initiator", "application"] {
509            assert_eq!(
510                format_contexts(role, &[]),
511                "(none — acts nowhere)",
512                "empty contexts must not read as unrestricted for role {role}"
513            );
514        }
515    }
516
517    /// The shape the `--approve-all` help text itself recommends: a reader
518    /// with no contexts whose authority is entirely `approve_scope`. It acts
519    /// nowhere, and the display must not suggest otherwise.
520    #[test]
521    fn test_least_privilege_approver_does_not_read_as_unrestricted() {
522        let contexts: Vec<String> = vec![];
523        assert_eq!(
524            format_contexts("reader", &contexts),
525            "(none — acts nowhere)"
526        );
527        assert_eq!(format_role("reader", &contexts), "reader");
528        assert_eq!(
529            format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
530            Some("contexts [openvtc]")
531        );
532    }
533
534    #[test]
535    fn test_format_approve_scope() {
536        assert_eq!(
537            format_approve_scope(true, &[]).as_deref(),
538            Some("all contexts")
539        );
540        assert_eq!(
541            format_approve_scope(false, &["openvtc".to_string()]).as_deref(),
542            Some("contexts [openvtc]")
543        );
544        assert_eq!(
545            format_approve_scope(false, &["a".to_string(), "b".to_string()]).as_deref(),
546            Some("contexts [a, b]")
547        );
548        // Confers nothing ⇒ no line.
549        assert_eq!(format_approve_scope(false, &[]), None);
550    }
551
552    #[test]
553    fn test_format_contexts_single() {
554        let ctx = vec!["vta".to_string()];
555        assert_eq!(format_contexts("reader", &ctx), "vta");
556    }
557
558    #[test]
559    fn test_format_contexts_multiple() {
560        let ctx = vec!["vta".to_string(), "payments".to_string()];
561        assert_eq!(format_contexts("reader", &ctx), "vta, payments");
562    }
563
564    // ── format_role ────────────────────────────────────────────────
565
566    #[test]
567    fn test_format_role_admin_no_contexts_is_super_admin() {
568        assert_eq!(format_role("admin", &[]), "super admin");
569    }
570
571    #[test]
572    fn test_format_role_admin_with_contexts_stays_admin() {
573        let ctx = vec!["vta".to_string()];
574        assert_eq!(format_role("admin", &ctx), "admin");
575    }
576
577    #[test]
578    fn test_format_role_initiator_unchanged() {
579        assert_eq!(format_role("initiator", &[]), "initiator");
580    }
581
582    #[test]
583    fn test_format_role_application_unchanged() {
584        let ctx = vec!["app".to_string()];
585        assert_eq!(format_role("application", &ctx), "application");
586    }
587
588    // ── validate_role ──────────────────────────────────────────────
589
590    #[test]
591    fn test_validate_role_admin_ok() {
592        assert!(validate_role("admin").is_ok());
593    }
594
595    #[test]
596    fn test_validate_role_initiator_ok() {
597        assert!(validate_role("initiator").is_ok());
598    }
599
600    #[test]
601    fn test_validate_role_application_ok() {
602        assert!(validate_role("application").is_ok());
603    }
604
605    #[test]
606    fn test_validate_role_reader_ok() {
607        assert!(validate_role("reader").is_ok());
608    }
609
610    #[test]
611    fn test_validate_role_unknown_fails() {
612        let err = validate_role("superuser").unwrap_err();
613        assert!(err.to_string().contains("invalid role 'superuser'"));
614    }
615
616    #[test]
617    fn test_validate_role_empty_fails() {
618        assert!(validate_role("").is_err());
619    }
620}