1use std::io::BufRead;
9use std::sync::Arc;
10
11use clap::Subcommand;
12
13use crate::cli::CliError;
14use crate::cli::render;
15use crate::cli::window::{DEFAULT_LIMIT, Window};
16use acme_proxy_admin::admin;
17use acme_proxy_core::audit::ALL_AUDIT_EVENTS;
18use acme_proxy_core::palette::Palette;
19use acme_proxy_store::audit::AuditQuery;
20use acme_proxy_store::db::Database;
21
22#[derive(Subcommand)]
23pub enum AuditCommand {
24 List {
26 #[arg(long)]
28 profile: Option<String>,
29 #[arg(long = "account-id")]
31 account_id: Option<String>,
32 #[arg(long = "order-id")]
34 order_id: Option<String>,
35 #[arg(long = "cert-serial")]
38 cert_serial: Option<String>,
39 #[arg(long)]
43 event: Option<String>,
44 #[arg(long)]
46 outcome: Option<String>,
47 #[arg(long = "since-days")]
49 since_days: Option<u64>,
50 #[arg(long, default_value_t = DEFAULT_LIMIT)]
52 limit: i64,
53 #[arg(long, default_value_t = 0)]
55 offset: i64,
56 #[arg(long)]
58 json: bool,
59 },
60 Show {
62 id: i64,
64 #[arg(long)]
66 json: bool,
67 },
68 Cleanup {
73 #[arg(long = "older-than", value_name = "DAYS")]
75 older_than: u64,
76 },
77}
78
79fn check_filters(event: Option<&str>, outcome: Option<&str>) -> Result<(), CliError> {
85 if let Some(event) = event
86 && acme_proxy_core::audit::AuditEvent::parse(event).is_none()
87 {
88 let known: Vec<&str> = ALL_AUDIT_EVENTS.iter().map(|e| e.as_str()).collect();
89 return Err(CliError::bad_request(format!(
90 "unknown --event `{event}`; known events are {}",
91 known.join(", ")
92 )));
93 }
94 if let Some(outcome) = outcome
95 && !matches!(outcome, "success" | "failure")
96 {
97 return Err(CliError::bad_request(format!(
98 "unknown --outcome `{outcome}`; expected `success` or `failure`"
99 )));
100 }
101 Ok(())
102}
103
104pub async fn run_audit_command(
105 command: AuditCommand,
106 yes: bool,
107 palette: Palette,
108 reader: &mut impl BufRead,
109 database: Arc<Database>,
110) -> Result<(), CliError> {
111 match command {
112 AuditCommand::List {
113 profile,
114 account_id,
115 order_id,
116 cert_serial,
117 event,
118 outcome,
119 since_days,
120 limit,
121 offset,
122 json,
123 } => {
124 check_filters(event.as_deref(), outcome.as_deref())?;
125 let window = Window::resolve(limit, offset);
126 let query = AuditQuery {
127 profile,
128 account_id,
129 order_id,
130 cert_serial: cert_serial
133 .as_deref()
134 .map(acme_proxy_core::cert::normalize_serial),
135 event,
136 outcome,
137 since: since_days.map(acme_proxy_store::audit::audit_cutoff),
138 limit: window.limit,
139 offset: window.offset,
140 };
141 let (entries, total) = admin::list_audit(&query, database).await?;
142 render::print_page(
143 &entries,
144 total,
145 window,
146 json,
147 acme_proxy_store::audit::AuditEntry::to_json,
148 |entry| render::render_audit_line(entry, palette),
149 );
150 }
151 AuditCommand::Show { id, json } => {
152 let Some(entry) = admin::find_audit(id, database).await? else {
153 return Err(CliError::bad_request(format!("audit row {id} not found")));
154 };
155 if json {
156 println!("{}", entry.to_json());
157 } else {
158 print!("{}", render::render_audit_detail_text(&entry, palette));
159 }
160 }
161 AuditCommand::Cleanup { older_than } => {
162 match admin::confirm_cleanup_audit(older_than, yes, reader, database.clone()).await? {
163 None => println!("Cancelled."),
164 Some(removed) => {
165 if removed > 0 {
170 acme_proxy_jobs::auditor::admin::record_cli_action(
171 &database,
172 |actor, client| {
173 acme_proxy_jobs::auditor::admin::audit_pruned(
174 actor, client, removed, older_than,
175 )
176 },
177 )
178 .await;
179 }
180 println!("Removed {removed} audit row(s).");
181 }
182 }
183 }
184 }
185 Ok(())
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::cli::CliErrorKind;
192 use acme_proxy_core::audit::{Actor, AuditRecord};
193 use acme_proxy_store::audit::AuditEntry;
194 use acme_proxy_store::db::Database;
195
196 async fn db_with_rows() -> Arc<Database> {
197 let db = Arc::new(Database::connect_in_memory().await.unwrap());
198 for event in ALL_AUDIT_EVENTS {
199 AuditEntry::insert(
200 AuditRecord::new(*event, "default", Actor::acme("acct-1"))
201 .with_account("acct-1")
202 .with_serial("0a0b"),
203 &db,
204 )
205 .await
206 .unwrap();
207 }
208 db
209 }
210
211 #[test]
215 fn an_unknown_event_or_outcome_is_refused_by_name() {
216 assert!(check_filters(None, None).is_ok());
217 assert!(check_filters(Some("certificate_issued"), Some("success")).is_ok());
218
219 let error = check_filters(Some("certificate_renewed"), None).unwrap_err();
220 assert!(error.message.contains("certificate_renewed"), "{error}");
221 assert!(error.message.contains("certificate_issued"), "{error}");
224 assert!(
225 error.message.contains("certificate_revoke_failed"),
226 "{error}"
227 );
228
229 let error = check_filters(None, Some("maybe")).unwrap_err();
230 assert!(error.message.contains("maybe"), "{error}");
231 assert!(error.message.contains("success"), "{error}");
232 assert_eq!(error.kind(), CliErrorKind::BadRequest);
233 }
234
235 fn list(json: bool) -> AuditCommand {
238 AuditCommand::List {
239 profile: None,
240 account_id: None,
241 order_id: None,
242 cert_serial: None,
243 event: None,
244 outcome: None,
245 since_days: None,
246 limit: DEFAULT_LIMIT,
247 offset: 0,
248 json,
249 }
250 }
251
252 fn list_window(limit: i64, offset: i64) -> AuditCommand {
253 AuditCommand::List {
254 profile: None,
255 account_id: None,
256 order_id: None,
257 cert_serial: None,
258 event: None,
259 outcome: None,
260 since_days: None,
261 limit,
262 offset,
263 json: false,
264 }
265 }
266
267 fn list_event(event: &str) -> AuditCommand {
268 AuditCommand::List {
269 profile: None,
270 account_id: None,
271 order_id: None,
272 cert_serial: None,
273 event: Some(event.to_string()),
274 outcome: None,
275 since_days: None,
276 limit: DEFAULT_LIMIT,
277 offset: 0,
278 json: false,
279 }
280 }
281
282 fn list_every_filter() -> AuditCommand {
285 AuditCommand::List {
286 profile: Some("default".to_string()),
287 account_id: Some("acct-1".to_string()),
288 order_id: Some("order-1".to_string()),
289 cert_serial: Some("0a0b".to_string()),
290 event: Some("certificate_issued".to_string()),
291 outcome: Some("success".to_string()),
292 since_days: Some(7),
293 limit: DEFAULT_LIMIT,
294 offset: 0,
295 json: false,
296 }
297 }
298
299 #[tokio::test]
303 async fn list_runs_in_both_shapes_and_clamps_a_nonsense_window() {
304 let db = db_with_rows().await;
305 let mut reader: &[u8] = &[];
306
307 run_audit_command(list(false), true, Palette::plain(), &mut reader, db.clone())
308 .await
309 .unwrap();
310 run_audit_command(list(true), true, Palette::plain(), &mut reader, db.clone())
311 .await
312 .unwrap();
313 run_audit_command(
314 list_window(0, -5),
315 true,
316 Palette::plain(),
317 &mut reader,
318 db.clone(),
319 )
320 .await
321 .unwrap();
322 run_audit_command(list_every_filter(), true, Palette::plain(), &mut reader, db)
323 .await
324 .unwrap();
325 }
326
327 #[tokio::test]
330 async fn list_refuses_an_unknown_event_before_querying() {
331 let db = Arc::new(Database::connect_in_memory().await.unwrap());
332 let mut reader: &[u8] = &[];
333 let error = run_audit_command(list_event("nope"), true, Palette::plain(), &mut reader, db)
334 .await
335 .unwrap_err();
336 assert!(error.message.contains("unknown --event"), "{error}");
337 assert_eq!(error.kind(), CliErrorKind::BadRequest);
338 }
339
340 #[tokio::test]
341 async fn show_renders_both_shapes_and_names_an_unknown_id() {
342 let db = db_with_rows().await;
343 let mut reader: &[u8] = &[];
344
345 for json in [false, true] {
346 run_audit_command(
347 AuditCommand::Show { id: 1, json },
348 true,
349 Palette::plain(),
350 &mut reader,
351 db.clone(),
352 )
353 .await
354 .unwrap();
355 }
356
357 let error = run_audit_command(
358 AuditCommand::Show {
359 id: 9_999,
360 json: false,
361 },
362 true,
363 Palette::plain(),
364 &mut reader,
365 db,
366 )
367 .await
368 .unwrap_err();
369 assert!(error.message.contains("9999"), "{error}");
370 assert_eq!(error.kind(), CliErrorKind::BadRequest);
371 }
372
373 #[tokio::test]
375 async fn cleanup_honours_the_prompt() {
376 let db = db_with_rows().await;
377
378 let mut declined: &[u8] = b"n\n";
379 run_audit_command(
380 AuditCommand::Cleanup { older_than: 0 },
381 false,
382 Palette::plain(),
383 &mut declined,
384 db.clone(),
385 )
386 .await
387 .unwrap();
388 assert_eq!(
389 AuditEntry::count_older_than(i64::MAX, &db).await.unwrap(),
390 ALL_AUDIT_EVENTS.len() as i64
391 );
392
393 let mut reader: &[u8] = &[];
396 run_audit_command(
397 AuditCommand::Cleanup { older_than: 365 },
398 true,
399 Palette::plain(),
400 &mut reader,
401 db.clone(),
402 )
403 .await
404 .unwrap();
405 assert_eq!(
406 AuditEntry::count_older_than(i64::MAX, &db).await.unwrap(),
407 ALL_AUDIT_EVENTS.len() as i64
408 );
409 }
410}