Skip to main content

acme_proxy/cli/
audit.rs

1use std::io::BufRead;
2use std::sync::Arc;
3
4use clap::Subcommand;
5
6use crate::admin;
7use crate::audit::ALL_AUDIT_EVENTS;
8use crate::cli::CliError;
9use crate::cli::render;
10use crate::cli::style::Palette;
11use crate::sqlite::audit::AuditQuery;
12use crate::sqlite::db::Database;
13
14/// Default rows per `audit list` page.
15///
16/// The table grows a row per issuance for the life of the deployment, so unlike
17/// `account list`/`order list` — which are unpaged and answer with whatever is
18/// there — this one has to have a window. `--limit 0` is not a way around it;
19/// there is deliberately no "everything" spelling, because on a year-old CA
20/// that is a terminal full of scrollback and a table loaded into memory.
21const DEFAULT_LIMIT: i64 = 50;
22
23#[derive(Subcommand)]
24pub enum AuditCommand {
25    /// List audit rows, newest first.
26    List {
27        #[arg(long)]
28        profile: Option<String>,
29        #[arg(long = "account-id")]
30        account_id: Option<String>,
31        #[arg(long = "order-id")]
32        order_id: Option<String>,
33        #[arg(long = "cert-serial")]
34        cert_serial: Option<String>,
35        /// One of `certificate_issued`, `certificate_issue_failed`,
36        /// `certificate_revoked`, `certificate_revoke_failed`.
37        #[arg(long)]
38        event: Option<String>,
39        /// `success` or `failure`.
40        #[arg(long)]
41        outcome: Option<String>,
42        /// Only rows from the last N days.
43        #[arg(long = "since-days")]
44        since_days: Option<u64>,
45        #[arg(long, default_value_t = DEFAULT_LIMIT)]
46        limit: i64,
47        #[arg(long, default_value_t = 0)]
48        offset: i64,
49        #[arg(long)]
50        json: bool,
51    },
52    /// Show one audit row in full.
53    Show {
54        id: i64,
55        #[arg(long)]
56        json: bool,
57    },
58    /// Delete audit rows older than a number of days.
59    ///
60    /// The only command in this binary that destroys audit history, which is
61    /// why it prompts with the number of rows it is about to remove.
62    Cleanup {
63        #[arg(long = "older-than")]
64        older_than: u64,
65    },
66}
67
68/// Rejects an `--event`/`--outcome` this build does not know.
69///
70/// Refused here rather than passed through to SQL, where an unknown value is
71/// not an error but an empty result — and "no rows" for a typo'd filter is the
72/// single most misleading answer an audit tool can give.
73fn check_filters(event: Option<&str>, outcome: Option<&str>) -> Result<(), CliError> {
74    if let Some(event) = event
75        && crate::audit::AuditEvent::parse(event).is_none()
76    {
77        let known: Vec<&str> = ALL_AUDIT_EVENTS.iter().map(|e| e.as_str()).collect();
78        return Err(CliError(format!(
79            "unknown --event `{event}`; known events are {}",
80            known.join(", ")
81        )));
82    }
83    if let Some(outcome) = outcome
84        && !matches!(outcome, "success" | "failure")
85    {
86        return Err(CliError(format!(
87            "unknown --outcome `{outcome}`; expected `success` or `failure`"
88        )));
89    }
90    Ok(())
91}
92
93pub async fn run_audit_command(
94    command: AuditCommand,
95    yes: bool,
96    palette: Palette,
97    reader: &mut impl BufRead,
98    database: Arc<Database>,
99) -> Result<(), CliError> {
100    match command {
101        AuditCommand::List {
102            profile,
103            account_id,
104            order_id,
105            cert_serial,
106            event,
107            outcome,
108            since_days,
109            limit,
110            offset,
111            json,
112        } => {
113            check_filters(event.as_deref(), outcome.as_deref())?;
114            let query = AuditQuery {
115                profile,
116                account_id,
117                order_id,
118                cert_serial,
119                event,
120                outcome,
121                since: since_days.map(admin::audit_cutoff),
122                limit: limit.max(1),
123                offset: offset.max(0),
124            };
125            let (entries, total) = admin::list_audit(&query, database).await?;
126            if json {
127                let rows: Vec<_> = entries
128                    .iter()
129                    .map(crate::sqlite::audit::AuditEntry::to_json)
130                    .collect();
131                println!("{}", serde_json::json!({ "total": total, "entries": rows }));
132            } else {
133                for entry in &entries {
134                    println!("{}", render::render_audit_line(entry, palette));
135                }
136                // Always, not only when the page is short: "42 of 1877" is the
137                // difference between having read the trail and having read a
138                // page of it, and the count is already computed.
139                println!("{} of {total} row(s).", entries.len());
140            }
141        }
142        AuditCommand::Show { id, json } => {
143            let Some(entry) = admin::find_audit(id, database).await? else {
144                return Err(CliError(format!("audit row {id} not found")));
145            };
146            if json {
147                println!("{}", entry.to_json());
148            } else {
149                print!("{}", render::render_audit_detail_text(&entry, palette));
150            }
151        }
152        AuditCommand::Cleanup { older_than } => {
153            match admin::confirm_cleanup_audit(older_than, yes, reader, database).await? {
154                None => println!("Cancelled."),
155                Some(removed) => println!("Removed {removed} audit row(s)."),
156            }
157        }
158    }
159    Ok(())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use acme_proxy_self::audit::{Actor, AuditRecord};
166    use acme_proxy_self::sqlite::audit::AuditEntry;
167    use acme_proxy_self::sqlite::db::Database;
168
169    // The crate refers to itself as `crate`; this alias keeps the imports above
170    // readable next to the `crate::` paths in the module body.
171    use crate as acme_proxy_self;
172
173    async fn db_with_rows() -> Arc<Database> {
174        let db = Arc::new(Database::connect_in_memory().await.unwrap());
175        for event in ALL_AUDIT_EVENTS {
176            AuditEntry::insert(
177                AuditRecord::new(*event, "default", Actor::acme("acct-1"))
178                    .with_account("acct-1")
179                    .with_serial("0a0b"),
180                &db,
181            )
182            .await
183            .unwrap();
184        }
185        db
186    }
187
188    /// A typo'd filter must be an error, not an empty result. "No rows" for a
189    /// misspelt `--event` is the most misleading answer an audit tool can give,
190    /// because it looks exactly like "nothing happened".
191    #[test]
192    fn an_unknown_event_or_outcome_is_refused_by_name() {
193        assert!(check_filters(None, None).is_ok());
194        assert!(check_filters(Some("certificate_issued"), Some("success")).is_ok());
195
196        let error = check_filters(Some("certificate_renewed"), None).unwrap_err();
197        assert!(error.0.contains("certificate_renewed"), "{error}");
198        // The message lists what *is* accepted, so the operator can fix it
199        // without reaching for the docs.
200        assert!(error.0.contains("certificate_issued"), "{error}");
201        assert!(error.0.contains("certificate_revoke_failed"), "{error}");
202
203        let error = check_filters(None, Some("maybe")).unwrap_err();
204        assert!(error.0.contains("maybe"), "{error}");
205        assert!(error.0.contains("success"), "{error}");
206    }
207
208    /// `AuditCommand::List` is an enum variant, so there is no functional
209    /// record update to lean on — each shape is spelled out.
210    fn list(json: bool) -> AuditCommand {
211        AuditCommand::List {
212            profile: None,
213            account_id: None,
214            order_id: None,
215            cert_serial: None,
216            event: None,
217            outcome: None,
218            since_days: None,
219            limit: DEFAULT_LIMIT,
220            offset: 0,
221            json,
222        }
223    }
224
225    fn list_window(limit: i64, offset: i64) -> AuditCommand {
226        AuditCommand::List {
227            profile: None,
228            account_id: None,
229            order_id: None,
230            cert_serial: None,
231            event: None,
232            outcome: None,
233            since_days: None,
234            limit,
235            offset,
236            json: false,
237        }
238    }
239
240    fn list_event(event: &str) -> AuditCommand {
241        AuditCommand::List {
242            profile: None,
243            account_id: None,
244            order_id: None,
245            cert_serial: None,
246            event: Some(event.to_string()),
247            outcome: None,
248            since_days: None,
249            limit: DEFAULT_LIMIT,
250            offset: 0,
251            json: false,
252        }
253    }
254
255    /// Every filter set at once, so none of them is a predicate that fails to
256    /// build once combined.
257    fn list_every_filter() -> AuditCommand {
258        AuditCommand::List {
259            profile: Some("default".to_string()),
260            account_id: Some("acct-1".to_string()),
261            order_id: Some("order-1".to_string()),
262            cert_serial: Some("0a0b".to_string()),
263            event: Some("certificate_issued".to_string()),
264            outcome: Some("success".to_string()),
265            since_days: Some(7),
266            limit: DEFAULT_LIMIT,
267            offset: 0,
268            json: false,
269        }
270    }
271
272    /// Both output shapes of `list`, plus the clamps: a `--limit 0` or a
273    /// negative `--offset` is nonsense the command corrects rather than a SQL
274    /// error the operator has to decode.
275    #[tokio::test]
276    async fn list_runs_in_both_shapes_and_clamps_a_nonsense_window() {
277        let db = db_with_rows().await;
278        let mut reader: &[u8] = &[];
279
280        run_audit_command(list(false), true, Palette::plain(), &mut reader, db.clone())
281            .await
282            .unwrap();
283        run_audit_command(list(true), true, Palette::plain(), &mut reader, db.clone())
284            .await
285            .unwrap();
286        run_audit_command(
287            list_window(0, -5),
288            true,
289            Palette::plain(),
290            &mut reader,
291            db.clone(),
292        )
293        .await
294        .unwrap();
295        run_audit_command(list_every_filter(), true, Palette::plain(), &mut reader, db)
296            .await
297            .unwrap();
298    }
299
300    /// The filter check runs before the query, so a bad `--event` fails without
301    /// touching the database.
302    #[tokio::test]
303    async fn list_refuses_an_unknown_event_before_querying() {
304        let db = Arc::new(Database::connect_in_memory().await.unwrap());
305        let mut reader: &[u8] = &[];
306        let error = run_audit_command(list_event("nope"), true, Palette::plain(), &mut reader, db)
307            .await
308            .unwrap_err();
309        assert!(error.0.contains("unknown --event"), "{error}");
310    }
311
312    #[tokio::test]
313    async fn show_renders_both_shapes_and_names_an_unknown_id() {
314        let db = db_with_rows().await;
315        let mut reader: &[u8] = &[];
316
317        for json in [false, true] {
318            run_audit_command(
319                AuditCommand::Show { id: 1, json },
320                true,
321                Palette::plain(),
322                &mut reader,
323                db.clone(),
324            )
325            .await
326            .unwrap();
327        }
328
329        let error = run_audit_command(
330            AuditCommand::Show {
331                id: 9_999,
332                json: false,
333            },
334            true,
335            Palette::plain(),
336            &mut reader,
337            db,
338        )
339        .await
340        .unwrap_err();
341        assert!(error.0.contains("9999"), "{error}");
342    }
343
344    /// Declining leaves the trail alone; accepting prunes by age.
345    #[tokio::test]
346    async fn cleanup_honours_the_prompt() {
347        let db = db_with_rows().await;
348
349        let mut declined: &[u8] = b"n\n";
350        run_audit_command(
351            AuditCommand::Cleanup { older_than: 0 },
352            false,
353            Palette::plain(),
354            &mut declined,
355            db.clone(),
356        )
357        .await
358        .unwrap();
359        assert_eq!(
360            AuditEntry::count_older_than(i64::MAX, &db).await.unwrap(),
361            4
362        );
363
364        // Nothing is a year old, so an accepted sweep still removes nothing —
365        // the cutoff, not the confirmation, is what bounds it.
366        let mut reader: &[u8] = &[];
367        run_audit_command(
368            AuditCommand::Cleanup { older_than: 365 },
369            true,
370            Palette::plain(),
371            &mut reader,
372            db.clone(),
373        )
374        .await
375        .unwrap();
376        assert_eq!(
377            AuditEntry::count_older_than(i64::MAX, &db).await.unwrap(),
378            4
379        );
380    }
381}