Skip to main content

acme_proxy/cli/
audit.rs

1//! `acme-proxy audit` — read the audit trail, and prune it.
2//!
3//! An unknown `--event` or `--outcome` is refused by name rather than passed
4//! to SQL, where it would answer "no rows". `cleanup` is the only command in the
5//! binary that destroys audit history, so it prompts with the row count and
6//! records its own row.
7
8use 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 audit rows, newest first.
25    List {
26        /// Only rows about this ACME endpoint.
27        #[arg(long)]
28        profile: Option<String>,
29        /// Only rows about this account.
30        #[arg(long = "account-id")]
31        account_id: Option<String>,
32        /// Only rows about this order.
33        #[arg(long = "order-id")]
34        order_id: Option<String>,
35        /// Only rows about the certificate with this serial, in hex. Case and
36        /// `:` or `-` separators do not matter.
37        #[arg(long = "cert-serial")]
38        cert_serial: Option<String>,
39        /// An audit event name — `certificate_issued`, `account_deleted`,
40        /// `operator_disabled`, … An unknown value is refused by name and the
41        /// full list printed.
42        #[arg(long)]
43        event: Option<String>,
44        /// `success` or `failure`.
45        #[arg(long)]
46        outcome: Option<String>,
47        /// Only rows from the last N days.
48        #[arg(long = "since-days")]
49        since_days: Option<u64>,
50        /// Rows per page. A value below 1 is read as 1.
51        #[arg(long, default_value_t = DEFAULT_LIMIT)]
52        limit: i64,
53        /// Rows to skip before the page starts.
54        #[arg(long, default_value_t = 0)]
55        offset: i64,
56        /// Print the page as JSON: `{items, total, limit, offset}`.
57        #[arg(long)]
58        json: bool,
59    },
60    /// Show one audit row in full.
61    Show {
62        /// The row id, as `audit list` prints it.
63        id: i64,
64        /// Print it as JSON.
65        #[arg(long)]
66        json: bool,
67    },
68    /// Delete audit rows older than a number of days.
69    ///
70    /// The only command in this binary that destroys audit history, which is
71    /// why it prompts with the number of rows it is about to remove.
72    Cleanup {
73        /// Delete rows older than this many days.
74        #[arg(long = "older-than", value_name = "DAYS")]
75        older_than: u64,
76    },
77}
78
79/// Rejects an `--event`/`--outcome` this build does not know.
80///
81/// Refused here rather than passed through to SQL, where an unknown value is
82/// not an error but an empty result — and "no rows" for a typo'd filter is the
83/// single most misleading answer an audit tool can give.
84fn 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                // See `order list --cert-serial`: the same fold, for the same
131                // reason, on the same column.
132                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                    // Written after the sweep, so the prune records its own
166                    // action rather than being caught by it. Only when it
167                    // actually removed something — a no-op prune changed
168                    // nothing, the `RevokeOutcome::AlreadyRevoked` rule.
169                    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    /// A typo'd filter must be an error, not an empty result. "No rows" for a
212    /// misspelt `--event` is the most misleading answer an audit tool can give,
213    /// because it looks exactly like "nothing happened".
214    #[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        // The message lists what *is* accepted, so the operator can fix it
222        // without reaching for the docs.
223        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    /// `AuditCommand::List` is an enum variant, so there is no functional
236    /// record update to lean on — each shape is spelled out.
237    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    /// Every filter set at once, so none of them is a predicate that fails to
283    /// build once combined.
284    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    /// Both output shapes of `list`, plus the clamps: a `--limit 0` or a
300    /// negative `--offset` is nonsense the command corrects rather than a SQL
301    /// error the operator has to decode.
302    #[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    /// The filter check runs before the query, so a bad `--event` fails without
328    /// touching the database.
329    #[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    /// Declining leaves the trail alone; accepting prunes by age.
374    #[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        // Nothing is a year old, so an accepted sweep still removes nothing —
394        // the cutoff, not the confirmation, is what bounds it.
395        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}