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::*;
6use vta_sdk::protocols::audit_management::list::{AuditEnvelope, ListAuditLogsResultBody};
7
8use crate::display::{NameBook, book_from_acl, named_did_cell};
9use crate::render::{is_full_display, print_full_entry_owned, print_full_list_title, print_widget};
10
11/// Display audit logs with beautiful colored formatting.
12pub async fn cmd_list_audit_logs(
13    client: &VtaClient,
14    params: &ListAuditLogsBody,
15) -> Result<(), Box<dyn std::error::Error>> {
16    let result = client.list_audit_logs(params).await?;
17
18    if result.entries.is_empty() {
19        println!("  No audit log entries found.");
20        return Ok(());
21    }
22
23    // Audit rows carry only an actor DID — "who did this" is exactly the
24    // question a log is read to answer, so it is worth one extra request to
25    // put names on them. Best-effort: an operator may hold audit-read without
26    // ACL-read, and a naming failure must never fail the command.
27    let mut book = NameBook::new();
28    if let Ok(acl) = client.list_acl(None).await {
29        book_from_acl(&mut book, &acl.entries);
30    }
31
32    if is_full_display() {
33        print_full_list_title("Audit Log", result.entries.len());
34        for entry in &result.entries {
35            let ts = format_recorded_at(&entry.recorded_at);
36            let actor = entry.actor.as_deref().unwrap_or("—");
37            let target = entry.target.as_deref().unwrap_or("—");
38            let channel = detail_str(entry, "channel").unwrap_or_else(|| "—".to_string());
39            let context = entry.context_id.as_deref().unwrap_or("—");
40            let mut fields = vec![
41                ("ID", entry.event_id.clone()),
42                ("Timestamp", ts),
43                ("Action", entry.action.clone()),
44            ];
45            if let Some(name) = book.name_of(actor) {
46                fields.push(("Actor", name));
47            }
48            // Actor DID stays in full — an audit trail is evidence.
49            fields.push(("Actor DID", actor.to_string()));
50            fields.push(("Resource", target.to_string()));
51            fields.push(("Channel", channel));
52            fields.push(("Context", context.to_string()));
53            fields.push((
54                "Outcome",
55                entry.outcome.clone().unwrap_or_else(|| "—".to_string()),
56            ));
57            if let Some(reason) = detail_str(entry, "reason") {
58                fields.push(("Reason", reason));
59            }
60            print_full_entry_owned(&fields);
61        }
62        print_cursor_footer(&result);
63        return Ok(());
64    }
65
66    println!("\n  \x1b[1mAudit Log\x1b[0m\n");
67
68    // Build table rows
69    let rows: Vec<Row> = result
70        .entries
71        .iter()
72        .map(|entry| {
73            // Format timestamp in operator's local timezone.
74            let ts = format_recorded_at(&entry.recorded_at);
75            let outcome = entry.outcome.as_deref().unwrap_or("—");
76
77            // Color the outcome
78            let outcome_style = if outcome == "success" {
79                Style::default().fg(Color::Green)
80            } else if 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.target.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.as_deref().unwrap_or("\u{2014}")),
108                Cell::from(resource_display.to_string()),
109                Cell::from(Span::styled(outcome.to_string(), 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    print_cursor_footer(&result);
170
171    Ok(())
172}
173
174/// Render `recordedAt` (RFC 3339 on the wire) in the operator's local
175/// timezone, falling back to the raw string if it does not parse —
176/// an audit row must still be readable when its timestamp is odd.
177fn format_recorded_at(recorded_at: &str) -> String {
178    match chrono::DateTime::parse_from_rfc3339(recorded_at) {
179        Ok(dt) => crate::duration::format_local_datetime(dt.with_timezone(&chrono::Utc)),
180        Err(_) => recorded_at.to_string(),
181    }
182}
183
184/// Pull a string member out of the canonical `detail` object.
185fn detail_str(entry: &AuditEnvelope, key: &str) -> Option<String> {
186    entry
187        .detail
188        .get(key)
189        .and_then(|v| v.as_str())
190        .map(str::to_owned)
191}
192
193/// Print the continuation hint. Paging is by opaque cursor, so there
194/// is no page count to show — the only thing an operator can act on is
195/// whether another page exists and the token that fetches it.
196fn print_cursor_footer(result: &ListAuditLogsResultBody) {
197    if let Some(cursor) = &result.cursor {
198        println!(
199            "\n  \x1b[2mMore entries \u{2014} fetch the next page with --cursor {cursor}\x1b[0m"
200        );
201        println!("  \x1b[2m(keep the same filters; changing them invalidates the cursor)\x1b[0m");
202    }
203}
204
205/// Display the current audit retention period.
206pub async fn cmd_get_retention(client: &VtaClient) -> Result<(), Box<dyn std::error::Error>> {
207    let result = client.get_audit_retention().await?;
208    println!("\n  \x1b[1mAudit Retention\x1b[0m");
209    println!(
210        "  Retention period: \x1b[36m{}\x1b[0m days",
211        result.retention_days
212    );
213    println!();
214    Ok(())
215}
216
217/// Update the audit retention period.
218pub async fn cmd_update_retention(
219    client: &VtaClient,
220    days: u32,
221) -> Result<(), Box<dyn std::error::Error>> {
222    let result = client.update_audit_retention(days).await?;
223    println!(
224        "\n  \x1b[32m\u{2713}\x1b[0m Audit retention updated to \x1b[36m{}\x1b[0m days",
225        result.retention_days
226    );
227    println!();
228    Ok(())
229}