Skip to main content

acme_proxy_store/
audit.rs

1//! The `audit_log` model: append, read back, purge by age.
2//!
3//! Deliberately narrow. There is no setter of any kind and no `update`: a row
4//! is INSERTed once and thereafter only read or deleted wholesale by
5//! [`AuditEntry::cleanup`]. That is the whole reason the table has no foreign
6//! keys either — see the header comment in
7//! `crates/store/migrations/20260809120000_add_audit_log.sql`.
8
9use crate::sql::Row;
10use serde_json::{Value, json};
11use tracing::debug;
12
13use crate::db::Database;
14use crate::nonce::now_secs;
15use acme_proxy_core::audit::Actor;
16use acme_proxy_core::audit::AuditEvent;
17use acme_proxy_core::audit::AuditRecord;
18use acme_proxy_core::audit::ClientContext;
19use acme_proxy_core::datetime::rfc3339;
20
21/// One stored audit row.
22///
23/// `event` and `actor_kind` come back as the strings they were stored as rather
24/// than as [`AuditEvent`]/[`ActorKind`](acme_proxy_core::audit::ActorKind): a `CHECK` constraint written before a
25/// future variant existed is exactly the thing that would make a read of an
26/// older database fail, and a listing that renders an unrecognised event name
27/// is a better outcome than one that refuses to load. [`AuditEntry::event`]
28/// parses it for the callers that want the enum.
29#[derive(Debug, Clone)]
30pub struct AuditEntry {
31    pub id: i64,
32    pub created_at: i64,
33    pub event: String,
34    pub outcome: String,
35    pub profile: String,
36    pub actor_kind: String,
37    pub actor_id: Option<String>,
38    pub account_id: Option<String>,
39    pub order_id: Option<String>,
40    pub cert_serial: Option<String>,
41    pub identifiers: Vec<String>,
42    pub client_ip: Option<String>,
43    pub client_ptr: Option<String>,
44    pub user_agent: Option<String>,
45    pub request_id: Option<String>,
46    pub reason: Option<String>,
47    pub detail: Option<String>,
48}
49
50/// Every column, in one place: the page query, the count and the single-row
51/// read must select the same set or `from_row` fails on whichever forgot one.
52const COLUMNS: &str = "id, created_at, event, outcome, profile, actor_kind, actor_id, \
53                       account_id, order_id, cert_serial, identifiers, client_ip, client_ptr, \
54                       user_agent, request_id, reason, detail";
55
56/// The filters and page window [`AuditEntry::search`] applies.
57///
58/// Every filter is optional and an absent one imposes no constraint, so
59/// `AuditQuery { limit, offset, .. }` is "the newest page of everything".
60#[derive(Debug, Clone, Default)]
61pub struct AuditQuery {
62    pub profile: Option<String>,
63    pub account_id: Option<String>,
64    pub order_id: Option<String>,
65    pub cert_serial: Option<String>,
66    pub event: Option<String>,
67    pub outcome: Option<String>,
68    /// Lower bound on `created_at`, inclusive. The one filter that is not an
69    /// equality, which is why it is applied separately below.
70    pub since: Option<i64>,
71    pub limit: i64,
72    pub offset: i64,
73}
74
75impl AuditQuery {
76    /// Appends the `WHERE` clause shared by the page query and the count — one
77    /// function rather than two copies, for the reason
78    /// [`crate::order::OrderQuery::push_predicates`] gives: a filter
79    /// applied to only one of them reports a total the rows contradict.
80    fn push_predicates(&self, builder: &mut crate::sql::Builder) {
81        let mut separator = " WHERE ";
82        for (column, value) in [
83            ("profile = ", self.profile.as_ref()),
84            ("account_id = ", self.account_id.as_ref()),
85            ("order_id = ", self.order_id.as_ref()),
86            ("cert_serial = ", self.cert_serial.as_ref()),
87            ("event = ", self.event.as_ref()),
88            ("outcome = ", self.outcome.as_ref()),
89        ] {
90            if let Some(value) = value {
91                builder
92                    .push(separator)
93                    .push(column)
94                    .push_bind(value.clone());
95                separator = " AND ";
96            }
97        }
98        if let Some(since) = self.since {
99            builder
100                .push(separator)
101                .push("created_at >= ")
102                .push_bind(since);
103        }
104    }
105}
106
107impl AuditEntry {
108    fn from_row(row: Row) -> Result<Self, sqlx::Error> {
109        let identifiers_json: String = row.try_get("identifiers")?;
110        let identifiers: Vec<String> = serde_json::from_str(&identifiers_json)
111            .map_err(|error| sqlx::Error::Decode(Box::new(error)))?;
112
113        Ok(Self {
114            id: row.try_get("id")?,
115            created_at: row.try_get("created_at")?,
116            event: row.try_get("event")?,
117            outcome: row.try_get("outcome")?,
118            profile: row.try_get("profile")?,
119            actor_kind: row.try_get("actor_kind")?,
120            actor_id: row.try_get("actor_id")?,
121            account_id: row.try_get("account_id")?,
122            order_id: row.try_get("order_id")?,
123            cert_serial: row.try_get("cert_serial")?,
124            identifiers,
125            client_ip: row.try_get("client_ip")?,
126            client_ptr: row.try_get("client_ptr")?,
127            user_agent: row.try_get("user_agent")?,
128            request_id: row.try_get("request_id")?,
129            reason: row.try_get("reason")?,
130            detail: row.try_get("detail")?,
131        })
132    }
133
134    /// The parsed event, or `None` for a value this build does not know — see
135    /// the struct comment.
136    #[must_use]
137    pub fn event(&self) -> Option<AuditEvent> {
138        AuditEvent::parse(&self.event)
139    }
140
141    /// Appends one row, returning its assigned id.
142    ///
143    /// `outcome` is taken from [`AuditEvent::outcome`] rather than the caller,
144    /// so the two columns cannot disagree — there is one definition of which
145    /// events are failures and this is the only place it is read.
146    pub async fn insert(record: AuditRecord, database: &Database) -> Result<i64, sqlx::Error> {
147        let AuditRecord {
148            event,
149            profile,
150            actor,
151            account_id,
152            order_id,
153            cert_serial,
154            identifiers,
155            client,
156            reason,
157            detail,
158        } = record;
159        let Actor { kind, id: actor_id } = actor;
160        let ClientContext {
161            ip: client_ip,
162            ptr: client_ptr,
163            user_agent,
164            request_id,
165        } = client;
166
167        // `identifiers` is a `Vec<String>`, so serialization is infallible.
168        let identifiers_json = Value::from(identifiers).to_string();
169        let created_at = now_secs();
170
171        let id = crate::sql::query(
172            "INSERT INTO audit_log (created_at, event, outcome, profile, actor_kind, actor_id, \
173             account_id, order_id, cert_serial, identifiers, client_ip, client_ptr, user_agent, \
174             request_id, reason, detail) \
175             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id;",
176        )
177        .bind(created_at)
178        .bind(event.as_str())
179        .bind(event.outcome())
180        .bind(&profile)
181        .bind(kind.as_str())
182        .bind(&actor_id)
183        .bind(&account_id)
184        .bind(&order_id)
185        .bind(&cert_serial)
186        .bind(identifiers_json)
187        .bind(&client_ip)
188        .bind(&client_ptr)
189        .bind(&user_agent)
190        .bind(&request_id)
191        .bind(&reason)
192        .bind(&detail)
193        .fetch_one(database)
194        .await?
195        .try_get::<i64>("id")?;
196
197        debug!(
198            event = "db_audit_row_written",
199            outcome = "success",
200            audit_id = id,
201            audit_event = event.as_str(),
202            profile = %profile,
203            actor_kind = kind.as_str(),
204        );
205        Ok(id)
206    }
207
208    /// One row by id — what `acme-proxy audit show <id>` reads.
209    pub async fn find_by_id(id: i64, database: &Database) -> Result<Option<Self>, sqlx::Error> {
210        // A `sql::Builder` rather than `sql::query`, which takes only a
211        // `&'static str` and so cannot be handed the shared `COLUMNS`. `id`
212        // still goes through `push_bind`, so nothing is interpolated.
213        let mut query = crate::sql::Builder::new(
214            database.dialect(),
215            format!("SELECT {COLUMNS} FROM audit_log WHERE id = "),
216        );
217        query.push_bind(id);
218        let row = query.build().fetch_optional(database).await?;
219        row.map(Self::from_row).transpose()
220    }
221
222    /// One page of rows matching `query`, newest first, plus the total the same
223    /// predicate matches unpaged.
224    pub async fn search(
225        query: &AuditQuery,
226        database: &Database,
227    ) -> Result<(Vec<Self>, i64), sqlx::Error> {
228        debug!(
229            event = "db_audit_search_started",
230            outcome = "progress",
231            profile = ?query.profile,
232            account_id = ?query.account_id,
233            audit_event = ?query.event,
234            outcome = ?query.outcome,
235            limit = query.limit,
236            offset = query.offset,
237        );
238
239        let mut page = crate::sql::Builder::new(
240            database.dialect(),
241            format!("SELECT {COLUMNS} FROM audit_log"),
242        );
243        query.push_predicates(&mut page);
244        // Newest first. `id` breaks the tie rather than merely decorating the
245        // clause: `created_at` is whole seconds and this table takes several
246        // rows a second under load, so without it two rows could swap between
247        // pages and one would never be seen.
248        page.push(" ORDER BY created_at DESC, id DESC LIMIT ");
249        page.push_bind(query.limit);
250        page.push(" OFFSET ");
251        page.push_bind(query.offset);
252
253        let rows = page.build().fetch_all(database).await?;
254        let entries: Vec<Self> = rows
255            .into_iter()
256            .map(Self::from_row)
257            .collect::<Result<_, _>>()?;
258
259        let mut count =
260            crate::sql::Builder::new(database.dialect(), "SELECT COUNT(*) FROM audit_log");
261        query.push_predicates(&mut count);
262        let total: i64 = count.build().fetch_one(database).await?.try_get::<i64>(0)?;
263
264        Ok((entries, total))
265    }
266
267    /// How many rows are older than `cutoff` — what the purge prompt names
268    /// before deleting anything.
269    pub async fn count_older_than(cutoff: i64, database: &Database) -> Result<i64, sqlx::Error> {
270        crate::sql::query("SELECT COUNT(*) FROM audit_log WHERE created_at < ?;")
271            .bind(cutoff)
272            .fetch_one(database)
273            .await?
274            .try_get::<i64>(0)
275    }
276
277    /// Deletes every row older than `cutoff`, returning how many went.
278    ///
279    /// The only statement in the crate that removes an audit row, and it takes
280    /// an absolute cutoff rather than a duration so the CLI's `--older-than`
281    /// and the `audit.retention_days` sweep run the identical delete.
282    pub async fn cleanup(cutoff: i64, database: &Database) -> Result<u64, sqlx::Error> {
283        let deleted = crate::sql::query("DELETE FROM audit_log WHERE created_at < ?;")
284            .bind(cutoff)
285            .execute(database)
286            .await?
287            .rows_affected();
288        debug!(
289            event = "db_audit_cleanup",
290            outcome = "success",
291            rows_removed = deleted,
292            cutoff = cutoff
293        );
294        Ok(deleted)
295    }
296
297    /// The JSON shape the admin API serves.
298    ///
299    /// Absent members rather than `null` for the optional columns, the shape
300    /// `Account::to_json` and `Order::to_json` already use: a consumer reading
301    /// `client_ip` off a CLI-initiated revocation gets nothing, which is what
302    /// happened.
303    #[must_use]
304    pub fn to_json(&self) -> Value {
305        let mut value = json!({
306            "id": self.id,
307            "createdAt": rfc3339(self.created_at),
308            "event": self.event,
309            "outcome": self.outcome,
310            "profile": self.profile,
311            "actorKind": self.actor_kind,
312            "identifiers": self.identifiers,
313        });
314        let map = value
315            .as_object_mut()
316            .expect("the literal above is an object");
317        for (key, field) in [
318            ("actorId", self.actor_id.as_ref()),
319            ("accountId", self.account_id.as_ref()),
320            ("orderId", self.order_id.as_ref()),
321            ("certSerial", self.cert_serial.as_ref()),
322            ("clientIp", self.client_ip.as_ref()),
323            ("clientPtr", self.client_ptr.as_ref()),
324            ("userAgent", self.user_agent.as_ref()),
325            ("requestId", self.request_id.as_ref()),
326            ("reason", self.reason.as_ref()),
327            ("detail", self.detail.as_ref()),
328        ] {
329            if let Some(field) = field {
330                map.insert(key.to_string(), Value::from(field.clone()));
331            }
332        }
333        value
334    }
335}
336
337/// The `created_at` below which an audit row is past `retention_days`.
338///
339/// One function so `acme-proxy audit cleanup --older-than` and the
340/// `audit.retention_days` sweep delete the identical set — a CLI that computed
341/// its own cutoff would eventually disagree with the timer by a rounding rule.
342#[must_use]
343pub fn audit_cutoff(days: u64) -> i64 {
344    let seconds = i64::try_from(days.saturating_mul(24 * 60 * 60)).unwrap_or(i64::MAX);
345    crate::nonce::now_secs().saturating_sub(seconds)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use acme_proxy_core::audit::Actor;
352    use acme_proxy_core::audit::AuditEvent;
353    use acme_proxy_core::audit::ClientContext;
354    use std::sync::Arc;
355
356    async fn db() -> Arc<Database> {
357        Arc::new(Database::connect_for_test().await.unwrap())
358    }
359
360    fn client() -> ClientContext {
361        ClientContext {
362            ip: Some("203.0.113.7".to_string()),
363            ptr: Some("host.example.com".to_string()),
364            user_agent: Some("certbot/2.9.0".to_string()),
365            request_id: Some("req-1".to_string()),
366        }
367    }
368
369    fn record(event: AuditEvent, profile: &str) -> AuditRecord {
370        AuditRecord::new(event, profile, Actor::acme("acct-1"))
371    }
372
373    /// The two fields production only ever sets through `with_order`, which
374    /// needs a real `Order`. The struct's fields are public, so a test that
375    /// only cares about the column can set them directly rather than justify a
376    /// builder method with no caller outside this file.
377    fn with_subject(mut record: AuditRecord, order_id: &str, identifiers: &[&str]) -> AuditRecord {
378        record.order_id = Some(order_id.to_string());
379        record.identifiers = identifiers.iter().map(|v| (*v).to_string()).collect();
380        record
381    }
382
383    /// The round trip, and the one column no caller sets: `outcome` comes from
384    /// `AuditEvent::outcome`, so an insert cannot get it wrong.
385    #[tokio::test]
386    async fn a_row_round_trips_and_derives_its_own_outcome() {
387        let db = db().await;
388        let id = AuditEntry::insert(
389            with_subject(
390                record(AuditEvent::CertificateIssued, "le"),
391                "order-1",
392                &["a.example.com"],
393            )
394            .with_serial("0a0b")
395            .with_client(client()),
396            &db,
397        )
398        .await
399        .unwrap();
400
401        let entry = AuditEntry::find_by_id(id, &db).await.unwrap().unwrap();
402        assert_eq!(entry.event, "certificate_issued");
403        assert_eq!(entry.outcome, "success");
404        assert_eq!(entry.event(), Some(AuditEvent::CertificateIssued));
405        assert_eq!(entry.profile, "le");
406        assert_eq!(entry.actor_kind, "acme");
407        assert_eq!(entry.actor_id.as_deref(), Some("acct-1"));
408        assert_eq!(entry.order_id.as_deref(), Some("order-1"));
409        assert_eq!(entry.cert_serial.as_deref(), Some("0a0b"));
410        assert_eq!(entry.identifiers, vec!["a.example.com"]);
411        assert_eq!(entry.client_ip.as_deref(), Some("203.0.113.7"));
412        assert_eq!(entry.client_ptr.as_deref(), Some("host.example.com"));
413        assert_eq!(entry.user_agent.as_deref(), Some("certbot/2.9.0"));
414        assert_eq!(entry.request_id.as_deref(), Some("req-1"));
415
416        let failed = AuditEntry::insert(
417            record(AuditEvent::CertificateRevokeFailed, "le").with_reason("unauthorized"),
418            &db,
419        )
420        .await
421        .unwrap();
422        let entry = AuditEntry::find_by_id(failed, &db).await.unwrap().unwrap();
423        assert_eq!(entry.outcome, "failure");
424        assert!(entry.identifiers.is_empty());
425        assert_eq!(entry.client_ip, None);
426
427        assert!(AuditEntry::find_by_id(9_999, &db).await.unwrap().is_none());
428    }
429
430    /// The property the whole table is shaped around: an audit row outlives the
431    /// account and the order it names. There is no foreign key, so deleting
432    /// either must leave the row — and its frozen identifiers — untouched.
433    #[tokio::test]
434    async fn a_row_survives_the_account_and_order_it_names_being_deleted() {
435        let db = db().await;
436        let account_id = crate::testutil::account_id(&db).await;
437        let order = crate::order::Order::new(
438            "default",
439            account_id,
440            vec![acme_proxy_core::identifier::Identifier::dns(
441                "a.example.com",
442            )],
443            0,
444            None,
445            None,
446        );
447        order.insert(&db).await.unwrap();
448
449        let id = AuditEntry::insert(
450            AuditRecord::new(
451                AuditEvent::CertificateIssued,
452                "default",
453                Actor::acme(account_id.to_string()),
454            )
455            .with_order(order.id, order.account_id, &order.identifiers),
456            &db,
457        )
458        .await
459        .unwrap();
460
461        // The cascade takes the order with the account; neither takes the row.
462        crate::account::Account::delete(account_id.to_string().as_str(), &db)
463            .await
464            .unwrap();
465        assert!(
466            crate::order::Order::find_by_id(order.id.to_string().as_str(), &db)
467                .await
468                .unwrap()
469                .is_none()
470        );
471
472        let entry = AuditEntry::find_by_id(id, &db).await.unwrap().unwrap();
473        assert_eq!(entry.account_id, Some(account_id.to_string()));
474        assert_eq!(entry.order_id, Some(order.id.to_string()));
475        assert_eq!(entry.identifiers, vec!["a.example.com"]);
476    }
477
478    /// Every filter, applied to the page *and* the count — a predicate on only
479    /// one of them reports a total the rows contradict.
480    #[tokio::test]
481    async fn every_filter_narrows_the_page_and_the_total_together() {
482        let db = db().await;
483        for (event, profile, account, serial) in [
484            (AuditEvent::CertificateIssued, "le", "acct-1", "aa"),
485            (AuditEvent::CertificateIssued, "le", "acct-2", "bb"),
486            (AuditEvent::CertificateIssueFailed, "le", "acct-1", "cc"),
487            (AuditEvent::CertificateRevoked, "internal", "acct-1", "dd"),
488        ] {
489            AuditEntry::insert(
490                AuditRecord::new(event, profile, Actor::acme(account))
491                    // `Actor::acme` fills `actor_id`; the *subject* column the
492                    // filter reads is `account_id`, and the two are not the
493                    // same field — an admin revoking somebody else's order
494                    // writes a row where they differ.
495                    .with_account(account)
496                    .with_serial(serial),
497                &db,
498            )
499            .await
500            .unwrap();
501        }
502
503        let count = async |query: AuditQuery| {
504            let (rows, total) = AuditEntry::search(&query, &db).await.unwrap();
505            assert_eq!(rows.len() as i64, total, "page and total must agree");
506            total
507        };
508        let base = AuditQuery {
509            limit: 50,
510            ..AuditQuery::default()
511        };
512
513        assert_eq!(count(base.clone()).await, 4);
514        assert_eq!(
515            count(AuditQuery {
516                profile: Some("le".to_string()),
517                ..base.clone()
518            })
519            .await,
520            3
521        );
522        assert_eq!(
523            count(AuditQuery {
524                account_id: Some("acct-1".to_string()),
525                ..base.clone()
526            })
527            .await,
528            3
529        );
530        assert_eq!(
531            count(AuditQuery {
532                cert_serial: Some("dd".to_string()),
533                ..base.clone()
534            })
535            .await,
536            1
537        );
538        assert_eq!(
539            count(AuditQuery {
540                event: Some("certificate_issued".to_string()),
541                ..base.clone()
542            })
543            .await,
544            2
545        );
546        assert_eq!(
547            count(AuditQuery {
548                outcome: Some("failure".to_string()),
549                ..base.clone()
550            })
551            .await,
552            1
553        );
554        // Combined, and an unknown value narrows to nothing rather than erroring.
555        assert_eq!(
556            count(AuditQuery {
557                profile: Some("le".to_string()),
558                outcome: Some("success".to_string()),
559                account_id: Some("acct-1".to_string()),
560                ..base.clone()
561            })
562            .await,
563            1
564        );
565        assert_eq!(
566            count(AuditQuery {
567                event: Some("certificate_renewed".to_string()),
568                ..base
569            })
570            .await,
571            0
572        );
573    }
574
575    /// Newest first, with `id` breaking the tie — every row here lands in the
576    /// same whole second, which is exactly the case the tiebreak exists for:
577    /// without it a row could swap between pages and never be seen.
578    #[tokio::test]
579    async fn paging_one_row_at_a_time_sees_every_row_exactly_once_newest_first() {
580        let db = db().await;
581        let mut inserted = Vec::new();
582        for index in 0..5 {
583            inserted.push(
584                AuditEntry::insert(
585                    record(AuditEvent::CertificateIssued, "le")
586                        .with_serial(format!("serial-{index}")),
587                    &db,
588                )
589                .await
590                .unwrap(),
591            );
592        }
593
594        let mut seen = Vec::new();
595        for offset in 0..5 {
596            let (rows, total) = AuditEntry::search(
597                &AuditQuery {
598                    limit: 1,
599                    offset,
600                    ..AuditQuery::default()
601                },
602                &db,
603            )
604            .await
605            .unwrap();
606            assert_eq!(total, 5);
607            seen.push(rows[0].id);
608        }
609
610        inserted.reverse();
611        assert_eq!(seen, inserted);
612    }
613
614    /// The purge: counted before it runs (that number words the prompt), and
615    /// bounded strictly by the cutoff so a row exactly on it survives.
616    #[tokio::test]
617    async fn cleanup_removes_only_what_is_strictly_older_than_the_cutoff() {
618        let db = db().await;
619        for _ in 0..3 {
620            AuditEntry::insert(record(AuditEvent::CertificateIssued, "le"), &db)
621                .await
622                .unwrap();
623        }
624        // The boundary comes off the oldest row rather than from a second clock
625        // read: `created_at` is whole seconds, so a boundary falling between the
626        // inserts and the read would put every row strictly before "now" and the
627        // assertions below would be pinning nothing. Rows come back newest
628        // first, so the last of the page is the oldest.
629        let (rows, _) = AuditEntry::search(
630            &AuditQuery {
631                limit: 50,
632                ..AuditQuery::default()
633            },
634            &db,
635        )
636        .await
637        .unwrap();
638        let oldest = rows.last().unwrap().created_at;
639
640        // Nothing is older than "an hour ago".
641        let past = oldest - 3600;
642        assert_eq!(AuditEntry::count_older_than(past, &db).await.unwrap(), 0);
643        assert_eq!(AuditEntry::cleanup(past, &db).await.unwrap(), 0);
644
645        // Nor exactly at `created_at`: the predicate is `<`, not `<=`.
646        assert_eq!(AuditEntry::count_older_than(oldest, &db).await.unwrap(), 0);
647
648        let future = oldest + 3600;
649        assert_eq!(AuditEntry::count_older_than(future, &db).await.unwrap(), 3);
650        assert_eq!(AuditEntry::cleanup(future, &db).await.unwrap(), 3);
651        assert_eq!(AuditEntry::count_older_than(future, &db).await.unwrap(), 0);
652    }
653
654    /// The JSON shape: optional columns are **absent**, not `null`, matching
655    /// `Account::to_json`/`Order::to_json`.
656    #[tokio::test]
657    async fn to_json_omits_the_columns_that_have_no_value() {
658        let db = db().await;
659        let bare = AuditEntry::insert(
660            AuditRecord::new(AuditEvent::CertificateRevoked, "le", Actor::system()),
661            &db,
662        )
663        .await
664        .unwrap();
665        let json = AuditEntry::find_by_id(bare, &db)
666            .await
667            .unwrap()
668            .unwrap()
669            .to_json();
670        let object = json.as_object().unwrap();
671
672        assert_eq!(object["event"], "certificate_revoked");
673        assert_eq!(object["outcome"], "success");
674        assert_eq!(object["actorKind"], "system");
675        assert_eq!(object["identifiers"], serde_json::json!([]));
676        assert!(object["createdAt"].as_str().unwrap().contains('T'));
677        for absent in [
678            "actorId",
679            "accountId",
680            "orderId",
681            "certSerial",
682            "clientIp",
683            "clientPtr",
684            "userAgent",
685            "requestId",
686            "reason",
687            "detail",
688        ] {
689            assert!(!object.contains_key(absent), "{absent} should be absent");
690        }
691
692        let full = AuditEntry::insert(
693            record(AuditEvent::CertificateIssueFailed, "le")
694                .with_client(client())
695                .with_reason("badCSR")
696                .with_detail("nope"),
697            &db,
698        )
699        .await
700        .unwrap();
701        let json = AuditEntry::find_by_id(full, &db)
702            .await
703            .unwrap()
704            .unwrap()
705            .to_json();
706        assert_eq!(json["clientIp"], "203.0.113.7");
707        assert_eq!(json["clientPtr"], "host.example.com");
708        assert_eq!(json["reason"], "badCSR");
709        assert_eq!(json["detail"], "nope");
710    }
711
712    /// An `event` this build does not know still loads *and* still inserts:
713    /// `20260909120000` dropped the `event` `CHECK` (the Rust enum is the
714    /// authority), and a reader that refused an unrecognised value would make a
715    /// newer server's rows unreadable by an older one.
716    #[tokio::test]
717    async fn an_unknown_event_string_still_loads_and_simply_does_not_parse() {
718        let db = db().await;
719        // A value no `AuditEvent` variant spells — accepted by the schema now.
720        crate::sql::query(
721            "INSERT INTO audit_log (created_at, event, outcome, profile, actor_kind) \
722             VALUES (0, 'certificate_renewed', 'success', 'le', 'acme');",
723        )
724        .execute(&db)
725        .await
726        .unwrap();
727        let entry = AuditEntry::search(
728            &AuditQuery {
729                limit: 1,
730                ..AuditQuery::default()
731            },
732            &db,
733        )
734        .await
735        .unwrap()
736        .0
737        .remove(0);
738        assert_eq!(entry.event, "certificate_renewed");
739        assert_eq!(entry.event(), None);
740    }
741
742    /// `outcome` and `actor_kind` keep their `CHECK`s — those two vocabularies
743    /// are closed. `event`'s was dropped by `20260909120000` (see above).
744    #[tokio::test]
745    async fn the_schema_refuses_an_outcome_or_actor_it_does_not_know() {
746        let db = db().await;
747        for (outcome, actor) in [("maybe", "acme"), ("success", "robot")] {
748            let error = crate::sql::query(
749                "INSERT INTO audit_log (created_at, event, outcome, profile, actor_kind) \
750                 VALUES (0, 'certificate_issued', ?, 'le', ?);",
751            )
752            .bind(outcome)
753            .bind(actor)
754            .execute(&db)
755            .await
756            .unwrap_err();
757            assert!(
758                crate::sql::is_check_violation(&error),
759                "{outcome}/{actor} was accepted: {error}"
760            );
761        }
762    }
763
764    /// One cutoff function, so `audit cleanup --older-than` and the
765    /// `audit.retention_days` sweep delete the identical set.
766    #[test]
767    fn the_audit_cutoff_is_days_before_now_and_saturates_rather_than_overflowing() {
768        let now = crate::nonce::now_secs();
769        assert!((audit_cutoff(0) - now).abs() <= 1);
770        let week = audit_cutoff(7);
771        assert!((now - week - 7 * 24 * 60 * 60).abs() <= 1, "{week}");
772        // A nonsense retention must not panic in a debug build.
773        assert!(audit_cutoff(u64::MAX) <= now);
774    }
775}