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::prelude::*;
7
8use crate::render::{is_full_display, print_full_entry, print_full_list_title, print_widget};
9
10pub fn format_contexts(contexts: &[String]) -> String {
11    if contexts.is_empty() {
12        "(unrestricted)".to_string()
13    } else {
14        contexts.join(", ")
15    }
16}
17
18pub fn format_role(role: &str, contexts: &[String]) -> String {
19    if role == "admin" && contexts.is_empty() {
20        "super admin".to_string()
21    } else {
22        role.to_string()
23    }
24}
25
26pub fn validate_role(role: &str) -> Result<(), Box<dyn std::error::Error>> {
27    match role {
28        "admin" | "initiator" | "application" | "reader" => Ok(()),
29        _ => Err(format!(
30            "invalid role '{role}', expected: admin, initiator, application, or reader"
31        )
32        .into()),
33    }
34}
35
36pub async fn cmd_acl_list(
37    client: &VtaClient,
38    context: Option<&str>,
39) -> Result<(), Box<dyn std::error::Error>> {
40    let resp = client.list_acl(context).await?;
41
42    // `--json` short-circuits all rendering and emits a single JSON
43    // document. Empty result returns an empty array, NOT a printed
44    // "no entries" string — automation scripts depend on the JSON
45    // shape being consistent across populated and empty results.
46    if crate::render::is_json_output() {
47        crate::render::print_json(&resp.entries)?;
48        return Ok(());
49    }
50
51    if resp.entries.is_empty() {
52        println!("No ACL entries found.");
53        return Ok(());
54    }
55
56    if is_full_display() {
57        print_full_list_title("ACL Entries", resp.entries.len());
58        for entry in &resp.entries {
59            let label = entry.label.as_deref().unwrap_or("—");
60            let contexts = format_contexts(&entry.allowed_contexts);
61            let role = format_role(&entry.role, &entry.allowed_contexts);
62            print_full_entry(&[
63                ("DID", &entry.did),
64                ("Role", &role),
65                ("Label", label),
66                ("Contexts", &contexts),
67                ("Created By", &entry.created_by),
68            ]);
69        }
70        return Ok(());
71    }
72
73    let header_style = Style::default()
74        .fg(Color::White)
75        .add_modifier(Modifier::BOLD);
76    let header = Row::new(vec!["DID", "Role", "Label", "Contexts", "Created By"])
77        .style(header_style)
78        .bottom_margin(1);
79
80    let rows: Vec<Row> = resp
81        .entries
82        .iter()
83        .map(|entry| {
84            let label = entry.label.clone().unwrap_or_else(|| "\u{2014}".into());
85            let contexts = format_contexts(&entry.allowed_contexts);
86
87            Row::new(vec![
88                Cell::from(entry.did.clone()).style(Style::default().fg(Color::DarkGray)),
89                Cell::from(format_role(&entry.role, &entry.allowed_contexts)),
90                Cell::from(label),
91                Cell::from(contexts),
92                Cell::from(entry.created_by.clone()).style(Style::default().fg(Color::DarkGray)),
93            ])
94        })
95        .collect();
96
97    let title = format!(" ACL Entries ({}) ", resp.entries.len());
98
99    // `Created By` and `DID` hold full did:key values (~57 chars); use
100    // `Min` rather than fixed `Length` so they expand on wide terminals
101    // instead of truncating. Role / Contexts are short and bounded.
102    let table = Table::new(
103        rows,
104        [
105            Constraint::Min(60),    // DID
106            Constraint::Length(12), // Role
107            Constraint::Min(16),    // Label
108            Constraint::Length(24), // Contexts
109            Constraint::Min(52),    // Created By
110        ],
111    )
112    .header(header)
113    .column_spacing(2)
114    .block(
115        Block::bordered()
116            .title(title)
117            .border_style(Style::default().fg(Color::DarkGray)),
118    );
119
120    let height = resp.entries.len() as u16 + 4;
121    print_widget(table, height);
122
123    Ok(())
124}
125
126pub async fn cmd_acl_get(client: &VtaClient, did: &str) -> Result<(), Box<dyn std::error::Error>> {
127    let entry = client.get_acl(did).await?;
128    println!("DID:              {}", entry.did);
129    println!(
130        "Role:             {}",
131        format_role(&entry.role, &entry.allowed_contexts)
132    );
133    println!(
134        "Label:            {}",
135        entry.label.as_deref().unwrap_or("(not set)")
136    );
137    println!(
138        "Contexts:         {}",
139        format_contexts(&entry.allowed_contexts)
140    );
141    println!("Created At:       {}", entry.created_at);
142    println!("Created By:       {}", entry.created_by);
143    Ok(())
144}
145
146pub async fn cmd_acl_create(
147    client: &VtaClient,
148    did: String,
149    role: String,
150    label: Option<String>,
151    contexts: Vec<String>,
152    expires_at: Option<u64>,
153    step_up_approver: Option<String>,
154    step_up_require: Option<String>,
155) -> Result<(), Box<dyn std::error::Error>> {
156    validate_role(&role)?;
157    let mut req = CreateAclRequest::new(did, role).contexts(contexts);
158    if let Some(l) = label {
159        req = req.label(l);
160    }
161    if let Some(secs) = expires_at {
162        req = req.expires_at(secs);
163    }
164    if let Some(ref approver) = step_up_approver {
165        req = req.step_up_approver(approver.clone());
166    }
167    if let Some(ref require) = step_up_require {
168        req = req.step_up_require(require.clone());
169    }
170    let entry = client.create_acl(req).await?;
171    println!("ACL entry created:");
172    println!("  DID:        {}", entry.did);
173    println!(
174        "  Role:       {}",
175        format_role(&entry.role, &entry.allowed_contexts)
176    );
177    if let Some(label) = &entry.label {
178        println!("  Label:      {label}");
179    }
180    println!("  Contexts:   {}", format_contexts(&entry.allowed_contexts));
181    if let Some(approver) = &step_up_approver {
182        println!("  Step-up approver: {approver}");
183    }
184    if let Some(require) = &step_up_require {
185        println!("  Step-up require:  {require}");
186    }
187    match entry.expires_at {
188        Some(secs) => println!(
189            "  Expires at: {} ({})",
190            crate::duration::format_local_time(secs),
191            crate::duration::format_remaining(secs),
192        ),
193        None => println!("  Expires at: (permanent)"),
194    }
195    Ok(())
196}
197
198pub async fn cmd_acl_update(
199    client: &VtaClient,
200    did: &str,
201    role: Option<String>,
202    label: Option<String>,
203    contexts: Option<Vec<String>>,
204    step_up_approver: Option<String>,
205    step_up_require: Option<String>,
206) -> Result<(), Box<dyn std::error::Error>> {
207    if let Some(ref r) = role {
208        validate_role(r)?;
209    }
210    let req = UpdateAclRequest {
211        role,
212        label,
213        allowed_contexts: contexts,
214        step_up_approver: step_up_approver.clone(),
215        step_up_require: step_up_require.clone(),
216    };
217    let entry = client.update_acl(did, req).await?;
218    println!("ACL entry updated:");
219    println!("  DID:      {}", entry.did);
220    println!(
221        "  Role:     {}",
222        format_role(&entry.role, &entry.allowed_contexts)
223    );
224    if let Some(label) = &entry.label {
225        println!("  Label:    {label}");
226    }
227    println!("  Contexts: {}", format_contexts(&entry.allowed_contexts));
228    if let Some(approver) = &step_up_approver {
229        if approver.is_empty() {
230            println!("  Step-up approver: (cleared)");
231        } else {
232            println!("  Step-up approver: {approver}");
233        }
234    }
235    if let Some(require) = &step_up_require {
236        if require.is_empty() {
237            println!("  Step-up require:  (cleared)");
238        } else {
239            println!("  Step-up require:  {require}");
240        }
241    }
242    Ok(())
243}
244
245pub async fn cmd_acl_delete(
246    client: &VtaClient,
247    did: &str,
248) -> Result<(), Box<dyn std::error::Error>> {
249    client.delete_acl(did).await?;
250    println!("ACL entry deleted: {did}");
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    // ── format_contexts ────────────────────────────────────────────
259
260    #[test]
261    fn test_format_contexts_empty_shows_unrestricted() {
262        assert_eq!(format_contexts(&[]), "(unrestricted)");
263    }
264
265    #[test]
266    fn test_format_contexts_single() {
267        let ctx = vec!["vta".to_string()];
268        assert_eq!(format_contexts(&ctx), "vta");
269    }
270
271    #[test]
272    fn test_format_contexts_multiple() {
273        let ctx = vec!["vta".to_string(), "payments".to_string()];
274        assert_eq!(format_contexts(&ctx), "vta, payments");
275    }
276
277    // ── format_role ────────────────────────────────────────────────
278
279    #[test]
280    fn test_format_role_admin_no_contexts_is_super_admin() {
281        assert_eq!(format_role("admin", &[]), "super admin");
282    }
283
284    #[test]
285    fn test_format_role_admin_with_contexts_stays_admin() {
286        let ctx = vec!["vta".to_string()];
287        assert_eq!(format_role("admin", &ctx), "admin");
288    }
289
290    #[test]
291    fn test_format_role_initiator_unchanged() {
292        assert_eq!(format_role("initiator", &[]), "initiator");
293    }
294
295    #[test]
296    fn test_format_role_application_unchanged() {
297        let ctx = vec!["app".to_string()];
298        assert_eq!(format_role("application", &ctx), "application");
299    }
300
301    // ── validate_role ──────────────────────────────────────────────
302
303    #[test]
304    fn test_validate_role_admin_ok() {
305        assert!(validate_role("admin").is_ok());
306    }
307
308    #[test]
309    fn test_validate_role_initiator_ok() {
310        assert!(validate_role("initiator").is_ok());
311    }
312
313    #[test]
314    fn test_validate_role_application_ok() {
315        assert!(validate_role("application").is_ok());
316    }
317
318    #[test]
319    fn test_validate_role_reader_ok() {
320        assert!(validate_role("reader").is_ok());
321    }
322
323    #[test]
324    fn test_validate_role_unknown_fails() {
325        let err = validate_role("superuser").unwrap_err();
326        assert!(err.to_string().contains("invalid role 'superuser'"));
327    }
328
329    #[test]
330    fn test_validate_role_empty_fails() {
331        assert!(validate_role("").is_err());
332    }
333}