1use 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#[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
50const 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#[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 pub since: Option<i64>,
71 pub limit: i64,
72 pub offset: i64,
73}
74
75impl AuditQuery {
76 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 #[must_use]
137 pub fn event(&self) -> Option<AuditEvent> {
138 AuditEvent::parse(&self.event)
139 }
140
141 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 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 pub async fn find_by_id(id: i64, database: &Database) -> Result<Option<Self>, sqlx::Error> {
210 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 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 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 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 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 #[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#[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 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 #[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 #[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 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 #[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 .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 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 #[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 #[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 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 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 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 #[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 #[tokio::test]
717 async fn an_unknown_event_string_still_loads_and_simply_does_not_parse() {
718 let db = db().await;
719 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 #[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 #[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 assert!(audit_cutoff(u64::MAX) <= now);
774 }
775}