vta_cli_common/commands/
audit.rs1use 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
11pub 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 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 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 let rows: Vec<Row> = result
70 .entries
71 .iter()
72 .map(|entry| {
73 let ts = format_recorded_at(&entry.recorded_at);
75 let outcome = entry.outcome.as_deref().unwrap_or("—");
76
77 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 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 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 let table = Table::new(
154 rows,
155 [
156 Constraint::Length(25), Constraint::Length(22), Constraint::Min(30), Constraint::Min(16), Constraint::Length(20), ],
162 )
163 .header(header)
164 .column_spacing(2);
165
166 let height = row_count as u16 + 2; print_widget(table, height);
168
169 print_cursor_footer(&result);
170
171 Ok(())
172}
173
174fn 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
184fn 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
193fn 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
205pub 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
217pub 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}