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