Skip to main content

vta_cli_common/commands/
audit.rs

1use ratatui::layout::Constraint;
2use ratatui::style::{Color, Modifier, Style};
3use ratatui::text::Span;
4use ratatui::widgets::{Cell, Row, Table};
5use vta_sdk::prelude::*;
6
7use crate::display::{NameBook, book_from_acl, named_did_cell};
8use crate::render::{is_full_display, print_full_entry_owned, print_full_list_title, print_widget};
9
10/// Display audit logs with beautiful colored formatting.
11pub async fn cmd_list_audit_logs(
12    client: &VtaClient,
13    params: &ListAuditLogsBody,
14) -> Result<(), Box<dyn std::error::Error>> {
15    let result = client.list_audit_logs(params).await?;
16
17    if result.entries.is_empty() {
18        println!("  No audit log entries found.");
19        return Ok(());
20    }
21
22    // Audit rows carry only an actor DID — "who did this" is exactly the
23    // question a log is read to answer, so it is worth one extra request to
24    // put names on them. Best-effort: an operator may hold audit-read without
25    // ACL-read, and a naming failure must never fail the command.
26    let mut book = NameBook::new();
27    if let Ok(acl) = client.list_acl(None).await {
28        book_from_acl(&mut book, &acl.entries);
29    }
30
31    if is_full_display() {
32        print_full_list_title(
33            &format!(
34                "Audit Log (page {}/{}, {} total)",
35                result.page, result.total_pages, result.total
36            ),
37            result.entries.len(),
38        );
39        for entry in &result.entries {
40            let ts = crate::duration::format_local_time(entry.timestamp);
41            let resource = entry.resource.as_deref().unwrap_or("—");
42            let channel = entry.channel.as_deref().unwrap_or("—");
43            let context = entry.context_id.as_deref().unwrap_or("—");
44            let mut fields = vec![
45                ("ID", entry.id.clone()),
46                ("Timestamp", ts),
47                ("Action", entry.action.clone()),
48            ];
49            if let Some(name) = book.name_of(&entry.actor) {
50                fields.push(("Actor", name));
51            }
52            // Actor DID stays in full — an audit trail is evidence.
53            fields.push(("Actor DID", entry.actor.clone()));
54            fields.push(("Resource", resource.to_string()));
55            fields.push(("Channel", channel.to_string()));
56            fields.push(("Context", context.to_string()));
57            fields.push(("Outcome", entry.outcome.clone()));
58            print_full_entry_owned(&fields);
59        }
60        return Ok(());
61    }
62
63    // Page info header
64    println!(
65        "\n  \x1b[1mAudit Log\x1b[0m  \x1b[2m(page {}/{}, {} total entries)\x1b[0m\n",
66        result.page, result.total_pages, result.total
67    );
68
69    // Build table rows
70    let rows: Vec<Row> = result
71        .entries
72        .iter()
73        .map(|entry| {
74            // Format timestamp in operator's local timezone.
75            let ts = crate::duration::format_local_time(entry.timestamp);
76
77            // Color the outcome
78            let outcome_style = if entry.outcome == "success" {
79                Style::default().fg(Color::Green)
80            } else if entry.outcome.starts_with("denied") {
81                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
82            } else {
83                Style::default().fg(Color::Yellow)
84            };
85
86            // Color the action
87            let action_style = if entry.action.starts_with("auth.") {
88                Style::default().fg(Color::Cyan)
89            } else if entry.action.starts_with("key.") || entry.action.starts_with("seed.") {
90                Style::default().fg(Color::Magenta)
91            } else if entry.action.starts_with("acl.") {
92                Style::default().fg(Color::Yellow)
93            } else if entry.action.starts_with("session.") {
94                Style::default().fg(Color::Blue)
95            } else {
96                Style::default()
97            };
98
99            // Actor: the entry's name when we have one, else the shortened
100            // DID. (The previous `&entry.actor[..29]` sliced on a byte
101            // boundary and would panic on a multi-byte character.)
102            let resource_display = entry.resource.as_deref().unwrap_or("\u{2014}");
103
104            Row::new(vec![
105                Cell::from(Span::styled(ts, Style::default().fg(Color::DarkGray))),
106                Cell::from(Span::styled(entry.action.clone(), action_style)),
107                named_did_cell(&book, &entry.actor),
108                Cell::from(resource_display.to_string()),
109                Cell::from(Span::styled(entry.outcome.clone(), outcome_style)),
110            ])
111        })
112        .collect();
113
114    let header = Row::new(vec![
115        Cell::from(Span::styled(
116            "Timestamp",
117            Style::default()
118                .fg(Color::White)
119                .add_modifier(Modifier::BOLD),
120        )),
121        Cell::from(Span::styled(
122            "Action",
123            Style::default()
124                .fg(Color::White)
125                .add_modifier(Modifier::BOLD),
126        )),
127        Cell::from(Span::styled(
128            "Actor",
129            Style::default()
130                .fg(Color::White)
131                .add_modifier(Modifier::BOLD),
132        )),
133        Cell::from(Span::styled(
134            "Resource",
135            Style::default()
136                .fg(Color::White)
137                .add_modifier(Modifier::BOLD),
138        )),
139        Cell::from(Span::styled(
140            "Outcome",
141            Style::default()
142                .fg(Color::White)
143                .add_modifier(Modifier::BOLD),
144        )),
145    ]);
146
147    let row_count = result.entries.len();
148
149    // `Actor` holds DID strings that can run 50+ chars — use `Min` so
150    // the column expands on wide terminals rather than cutting off at
151    // a fixed 30 (operators still see the ellipsis-truncated DID on
152    // narrow screens, and can use `--full-display` for full values).
153    let table = Table::new(
154        rows,
155        [
156            Constraint::Length(25), // Timestamp (local tz with offset)
157            Constraint::Length(22), // Action
158            Constraint::Min(30),    // Actor
159            Constraint::Min(16),    // Resource
160            Constraint::Length(20), // Outcome
161        ],
162    )
163    .header(header)
164    .column_spacing(2);
165
166    let height = row_count as u16 + 2; // rows + header + spacing
167    print_widget(table, height);
168
169    // Footer with pagination info
170    if result.total_pages > 1 {
171        println!(
172            "\n  \x1b[2mPage {}/{} \u{2014} use --page N to navigate\x1b[0m",
173            result.page, result.total_pages
174        );
175    }
176
177    Ok(())
178}
179
180/// Display the current audit retention period.
181pub async fn cmd_get_retention(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
182    let result = client.get_audit_retention().await?;
183    println!("\n  \x1b[1mAudit Retention\x1b[0m");
184    println!(
185        "  Retention period: \x1b[36m{}\x1b[0m days",
186        result.retention_days
187    );
188    println!();
189    Ok(())
190}
191
192/// Update the audit retention period.
193pub async fn cmd_update_retention(
194    client: &VtaClient,
195    days: u32,
196) -> Result<(), Box<dyn std::error::Error>> {
197    let result = client.update_audit_retention(days).await?;
198    println!(
199        "\n  \x1b[32m\u{2713}\x1b[0m Audit retention updated to \x1b[36m{}\x1b[0m days",
200        result.retention_days
201    );
202    println!();
203    Ok(())
204}