Skip to main content

acme_proxy/audit/
mod.rs

1//! The CA's audit trail: who asked this server to sign or withdraw a
2//! certificate, from where, and how it ended.
3//!
4//! Three things live here, and they are deliberately one module rather than
5//! three:
6//!
7//! - the **vocabulary** ([`AuditEvent`], [`Actor`], [`AuditRecord`]) every call
8//!   site builds a row from;
9//! - the **reverse lookup**, which is the only part that touches the network and
10//!   the only part an operator can switch off (`audit.reverse_dns`);
11//! - the **write**, which is best-effort by design — see [`Auditor::record`].
12//!
13//! ## Why this is not [`notify`](crate::notify)
14//!
15//! The two fire at nearly the same call sites and carry nearly the same fields,
16//! which invites merging them. They answer different questions. A notification
17//! is *outbound and lossy*: it goes to a chat room, it is fire-and-forget, and a
18//! backend that is down loses the event with a warning. An audit row is
19//! *inbound and durable*: it is the record the CA is answerable for, it is
20//! queried months later by serial or by account, and it exists for the events
21//! nobody wants a notification about — the refusals. Notifications also fire for
22//! things that never touch the CA (`account_created`, `challenge_failed`), and
23//! the audit trail records things nothing is notified about. Sharing a type
24//! would mean every future field arguing about which of the two it is for.
25
26use std::net::IpAddr;
27use std::sync::Arc;
28use std::time::Duration;
29
30use axum::extract::FromRequestParts;
31use axum::http::request::Parts;
32use tracing::{debug, error, info};
33
34use crate::config::{AuditConfig, DnsConfig};
35use crate::dns::{HickoryResolver, Resolver, resolver_addr};
36use crate::sqlite::audit::AuditEntry;
37use crate::sqlite::db::Database;
38
39/// The four things this trail records: each CA action, and its refusal.
40///
41/// A refusal is an audit record in its own right. "Who tried to revoke this
42/// certificate and was turned away" is the question the successes cannot
43/// answer, and it is the one asked after something has gone wrong.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum AuditEvent {
46    CertificateIssued,
47    CertificateIssueFailed,
48    CertificateRevoked,
49    CertificateRevokeFailed,
50}
51
52impl AuditEvent {
53    /// The stored form, matching the `CHECK` in
54    /// `migrations/20260809120000_add_audit_log.sql`.
55    #[must_use]
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            Self::CertificateIssued => "certificate_issued",
59            Self::CertificateIssueFailed => "certificate_issue_failed",
60            Self::CertificateRevoked => "certificate_revoked",
61            Self::CertificateRevokeFailed => "certificate_revoke_failed",
62        }
63    }
64
65    /// `success` or `failure`, and the **only** definition of which is which.
66    ///
67    /// The column exists so "show me everything that was refused" is an index
68    /// lookup rather than `event LIKE '%_failed'` written out in the CLI, the
69    /// API and the page. Deriving it here rather than at each insert is what
70    /// stops the two columns ever disagreeing.
71    #[must_use]
72    pub fn outcome(&self) -> &'static str {
73        match self {
74            Self::CertificateIssued | Self::CertificateRevoked => "success",
75            Self::CertificateIssueFailed | Self::CertificateRevokeFailed => "failure",
76        }
77    }
78
79    /// Parses the stored form back. `None` for anything the `CHECK` would have
80    /// refused, which is also how the CLI validates `--event`.
81    #[must_use]
82    pub fn parse(value: &str) -> Option<Self> {
83        Some(match value {
84            "certificate_issued" => Self::CertificateIssued,
85            "certificate_issue_failed" => Self::CertificateIssueFailed,
86            "certificate_revoked" => Self::CertificateRevoked,
87            "certificate_revoke_failed" => Self::CertificateRevokeFailed,
88            _ => return None,
89        })
90    }
91}
92
93/// Every [`AuditEvent`], for the CLI's `--event` help text and the page's filter.
94pub const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
95    AuditEvent::CertificateIssued,
96    AuditEvent::CertificateIssueFailed,
97    AuditEvent::CertificateRevoked,
98    AuditEvent::CertificateRevokeFailed,
99];
100
101/// Which front end acted.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum ActorKind {
104    /// A certificate client over the ACME API.
105    Acme,
106    /// An operator through the web admin.
107    Admin,
108    /// `acme-proxy order revoke` on the host.
109    Cli,
110    /// The `relay` signer's background task, settling an issuance this
111    /// server already answered `processing`. The one actor with no request
112    /// behind it, and therefore no address — see [`Actor::system`].
113    System,
114}
115
116impl ActorKind {
117    #[must_use]
118    pub fn as_str(&self) -> &'static str {
119        match self {
120            Self::Acme => "acme",
121            Self::Admin => "admin",
122            Self::Cli => "cli",
123            Self::System => "system",
124        }
125    }
126}
127
128/// Who acted, and their identity within that kind.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Actor {
131    pub kind: ActorKind,
132    pub id: Option<String>,
133}
134
135impl Actor {
136    /// An ACME client acting as a known account.
137    #[must_use]
138    pub fn acme(account_id: impl Into<String>) -> Self {
139        Self {
140            kind: ActorKind::Acme,
141            id: Some(account_id.into()),
142        }
143    }
144
145    /// An ACME client that proved possession of the certificate's own key pair
146    /// and named no account — RFC 8555 §7.6's accountless revocation.
147    ///
148    /// The `None` is the honest answer and not a gap: there is no identity to
149    /// record beyond "whoever holds this certificate's private key", which the
150    /// `cert_serial` on the same row already says.
151    #[must_use]
152    pub fn acme_certificate_key() -> Self {
153        Self {
154            kind: ActorKind::Acme,
155            id: None,
156        }
157    }
158
159    /// An operator signed in to the web admin.
160    #[must_use]
161    pub fn admin(username: impl Into<String>) -> Self {
162        Self {
163            kind: ActorKind::Admin,
164            id: Some(username.into()),
165        }
166    }
167
168    /// The command line, identified by whichever of `$USER`/`$LOGNAME` is set.
169    ///
170    /// Advisory only, and unavoidably so: anything running this binary can set
171    /// those variables. It narrows "somebody on the host" to "somebody on the
172    /// host, probably this account", which is the most a process can say about
173    /// its own invoker without help from the audit subsystem of the OS.
174    #[must_use]
175    pub fn cli() -> Self {
176        let id = std::env::var("USER")
177            .or_else(|_| std::env::var("LOGNAME"))
178            .ok()
179            .filter(|value| !value.is_empty());
180        Self {
181            kind: ActorKind::Cli,
182            id,
183        }
184    }
185
186    /// This server's own background work.
187    #[must_use]
188    pub fn system() -> Self {
189        Self {
190            kind: ActorKind::System,
191            id: None,
192        }
193    }
194}
195
196/// The request a row came from: address, its reverse name, and the two headers
197/// worth keeping.
198///
199/// Entirely empty for [`ActorKind::Cli`] and [`ActorKind::System`], which is
200/// why every field is optional rather than a placeholder string — "there was no
201/// client" and "the client sent no User-Agent" are both `None`, and the
202/// `actor_kind` on the row already tells them apart.
203#[derive(Debug, Clone, Default, PartialEq, Eq)]
204pub struct ClientContext {
205    pub ip: Option<String>,
206    pub ptr: Option<String>,
207    pub user_agent: Option<String>,
208    pub request_id: Option<String>,
209}
210
211/// What a request carries before the reverse lookup has run.
212///
213/// An extractor rather than three `Extension`s at each call site: a handler
214/// that records an audit row wants all of this or none of it, and gathering it
215/// in one place is what keeps `User-Agent`'s truncation rule (below) from being
216/// re-decided per handler. Resolve it into a [`ClientContext`] with
217/// [`Auditor::client`].
218#[derive(Debug, Clone, Default)]
219pub struct RequestContext {
220    pub ip: Option<IpAddr>,
221    pub user_agent: Option<String>,
222    pub request_id: Option<String>,
223}
224
225/// Longest `User-Agent` kept. Real ones are well under this; the header is
226/// attacker-controlled and ends up in a database column and an HTML page, so it
227/// gets a ceiling rather than trust.
228const USER_AGENT_MAX: usize = 256;
229
230impl RequestContext {
231    /// Reads the address the filter middleware resolved, plus the two headers.
232    ///
233    /// Free of the request body, so this composes with `AcmeRequest<T>` — which
234    /// consumes it — in the usual axum order.
235    pub fn from_parts(parts: &Parts) -> Self {
236        Self::gather(&parts.headers, &parts.extensions)
237    }
238
239    /// Same, from a whole request.
240    ///
241    /// `verify_jws` needs this: it is handed the `Request` and consumes it into
242    /// a body string, so it has to read the context *before* the point where
243    /// the extractor machinery would hand it `Parts`.
244    pub fn from_request<B>(request: &axum::http::Request<B>) -> Self {
245        Self::gather(request.headers(), request.extensions())
246    }
247
248    fn gather(headers: &axum::http::HeaderMap, extensions: &axum::http::Extensions) -> Self {
249        let ip = extensions
250            .get::<crate::filter::ClientIp>()
251            .and_then(|client| client.0);
252        let user_agent = headers
253            .get(axum::http::header::USER_AGENT)
254            .and_then(|value| value.to_str().ok())
255            .map(|value| value.chars().take(USER_AGENT_MAX).collect::<String>())
256            .filter(|value| !value.is_empty());
257        let request_id = extensions
258            .get::<crate::middlewares::access::RequestId>()
259            .map(|id| id.0.clone());
260        Self {
261            ip,
262            user_agent,
263            request_id,
264        }
265    }
266}
267
268impl<S: Send + Sync> FromRequestParts<S> for RequestContext {
269    type Rejection = std::convert::Infallible;
270
271    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
272        Ok(Self::from_parts(parts))
273    }
274}
275
276/// One row, before it is written.
277///
278/// Built with [`AuditRecord::new`] plus the `with_*` setters rather than a
279/// struct literal: the four events populate different subsets — an issuance
280/// failure has no serial, an accountless revocation has no account — and a
281/// literal would mean a column of `None`s at every call site.
282#[derive(Debug, Clone)]
283pub struct AuditRecord {
284    pub event: AuditEvent,
285    pub profile: String,
286    pub actor: Actor,
287    pub account_id: Option<String>,
288    pub order_id: Option<String>,
289    pub cert_serial: Option<String>,
290    pub identifiers: Vec<String>,
291    pub client: ClientContext,
292    pub reason: Option<String>,
293    pub detail: Option<String>,
294}
295
296impl AuditRecord {
297    #[must_use]
298    pub fn new(event: AuditEvent, profile: impl Into<String>, actor: Actor) -> Self {
299        Self {
300            event,
301            profile: profile.into(),
302            actor,
303            account_id: None,
304            order_id: None,
305            cert_serial: None,
306            identifiers: Vec::new(),
307            client: ClientContext::default(),
308            reason: None,
309            detail: None,
310        }
311    }
312
313    /// Fills in the subject from the order: its id, its account and the names
314    /// it covers, the last frozen into the row rather than joined back — the
315    /// order may be deleted long before the row is read.
316    #[must_use]
317    pub fn with_order(mut self, order: &crate::sqlite::order::Order) -> Self {
318        self.order_id = Some(order.id.clone());
319        self.account_id = Some(order.account_id.clone());
320        self.identifiers = order
321            .identifiers
322            .iter()
323            .map(|identifier| identifier.value.clone())
324            .collect();
325        self
326    }
327
328    #[must_use]
329    pub fn with_account(mut self, account_id: impl Into<String>) -> Self {
330        self.account_id = Some(account_id.into());
331        self
332    }
333
334    #[must_use]
335    pub fn with_serial(mut self, serial: impl Into<String>) -> Self {
336        self.cert_serial = Some(serial.into());
337        self
338    }
339
340    #[must_use]
341    pub fn with_client(mut self, client: ClientContext) -> Self {
342        self.client = client;
343        self
344    }
345
346    /// The RFC 8555 problem type on a refusal, or the RFC 5280 reason code on a
347    /// revocation. Never both — see the column comment in the migration.
348    #[must_use]
349    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
350        self.reason = Some(reason.into());
351        self
352    }
353
354    #[must_use]
355    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
356        self.detail = Some(detail.into());
357        self
358    }
359}
360
361/// Writes audit rows, and resolves the reverse names that go in them.
362///
363/// One per process, shared by the ACME listener ([`crate::AppState`]), the web
364/// admin ([`crate::webadmin::AdminState`]) and the CLI. Process-wide because
365/// `[audit]` is: the trail describes the CA, not one of its endpoints.
366pub struct Auditor {
367    database: Arc<Database>,
368    /// `None` when `audit.reverse_dns` is off, which is what makes the switch
369    /// structural: there is no resolver to call rather than a boolean checked
370    /// at each call site. Same shape as `ChallengeRegistry`'s bypass flag
371    /// refusing to *construct* the validators.
372    resolver: Option<Arc<dyn Resolver>>,
373    ptr_timeout: Duration,
374    /// The process's Prometheus counters.
375    ///
376    /// `None` only for an auditor built through [`Auditor::with_resolver`],
377    /// which is test scaffolding. The serving path goes through
378    /// [`Auditor::from_config`], where it is a **required argument** rather
379    /// than a builder step — see that constructor.
380    metrics: Option<Arc<crate::metrics::Metrics>>,
381}
382
383impl Auditor {
384    /// Builds the auditor, and with it the **cached** resolver its PTR lookups
385    /// go through.
386    ///
387    /// Cached, unlike the shared resolver `Profile::build_all` threads through
388    /// the challenge and signer subsystems, and for the reason
389    /// `filter::reverse_dns` makes the same choice: a PTR record for an address
390    /// that keeps connecting is exactly what a cache is for, and there is no
391    /// just-published-record problem here — the answer being a few minutes old
392    /// is not a failure mode for a column that says "the name this address had
393    /// at the time".
394    ///
395    /// A second cached resolver rather than sharing `reverse_dns`'s: that one
396    /// is per-profile and built only when the filter is enabled, and reaching
397    /// across for it would tie the audit trail's completeness to whether an
398    /// unrelated filter happens to be switched on.
399    /// `metrics` is a required argument and deliberately not a builder step.
400    /// It was one, briefly, and the omission it invited happened immediately:
401    /// the serving path built its auditor without ever calling the builder, so
402    /// `acme_proxy_certificates_issued_total` stayed at zero in production
403    /// while every test passed — the test harness wired the registry itself, so
404    /// what the tests proved was the harness's wiring and not the server's. A
405    /// parameter cannot be forgotten. Test scaffolding that genuinely has no
406    /// registry uses [`Auditor::with_resolver`] instead.
407    pub fn from_config(
408        cfg: &AuditConfig,
409        dns: &DnsConfig,
410        database: Arc<Database>,
411        metrics: Arc<crate::metrics::Metrics>,
412    ) -> anyhow::Result<Self> {
413        let resolver: Option<Arc<dyn Resolver>> = if cfg.reverse_dns {
414            Some(Arc::new(match resolver_addr(dns)? {
415                Some(addr) => HickoryResolver::from_address(addr)
416                    .map_err(|error| anyhow::anyhow!("audit.reverse_dns: {error}"))?,
417                None => HickoryResolver::from_system()
418                    .map_err(|error| anyhow::anyhow!("audit.reverse_dns: {error}"))?,
419            }))
420        } else {
421            None
422        };
423        info!(
424            event = "audit_loaded",
425            outcome = "success",
426            reverse_dns = cfg.reverse_dns,
427            reverse_dns_timeout_ms = cfg.reverse_dns_timeout_ms,
428            retention_days = cfg.retention_days,
429        );
430        Ok(Self {
431            database,
432            resolver,
433            ptr_timeout: Duration::from_millis(cfg.reverse_dns_timeout_ms),
434            metrics: Some(metrics),
435        })
436    }
437
438    /// Same, against a caller-supplied resolver — or none, for the reverse
439    /// lookup switched off. Used by tests and by [`Self::from_config`].
440    #[must_use]
441    pub fn with_resolver(
442        database: Arc<Database>,
443        resolver: Option<Arc<dyn Resolver>>,
444        ptr_timeout: Duration,
445    ) -> Self {
446        Self {
447            database,
448            resolver,
449            ptr_timeout,
450            metrics: None,
451        }
452    }
453
454    /// The reverse name for `ip`, or `None`.
455    ///
456    /// Every failure is `None`: no PTR record, a resolver that timed out, a
457    /// SERVFAIL, `audit.reverse_dns` off, or no client address at all. Nothing
458    /// downstream distinguishes them, because nothing downstream *authorises*
459    /// on this value — it is a label on a row, and a label that is sometimes
460    /// missing is worth more than a request that failed to get one.
461    ///
462    /// The first name only when several PTR records answer. Storing all of them
463    /// would make the column a list nothing queries; `filter.reverse_dns` is
464    /// where multiple candidates genuinely matter, and it looks them up itself.
465    pub async fn reverse(&self, ip: Option<IpAddr>) -> Option<String> {
466        let (resolver, ip) = (self.resolver.as_ref()?, ip?);
467        match tokio::time::timeout(self.ptr_timeout, resolver.reverse(ip)).await {
468            Ok(Ok(names)) => names.into_iter().next(),
469            Ok(Err(error)) => {
470                debug!(event = "audit_reverse_dns_failed", outcome = "failure", ip = %ip, error = %error);
471                None
472            }
473            Err(_) => {
474                debug!(
475                    event = "audit_reverse_dns_timeout",
476                    outcome = "failure",
477                    ip = %ip,
478                    timeout_ms = crate::millis(self.ptr_timeout),
479                );
480                None
481            }
482        }
483    }
484
485    /// Resolves a [`RequestContext`] into the [`ClientContext`] a row stores,
486    /// running the reverse lookup on the way.
487    pub async fn client(&self, request: &RequestContext) -> ClientContext {
488        let canonical = request.ip.map(crate::filter::canonical);
489        ClientContext {
490            ip: canonical.map(|ip| ip.to_string()),
491            ptr: self.reverse(canonical).await,
492            user_agent: request.user_agent.clone(),
493            request_id: request.request_id.clone(),
494        }
495    }
496
497    /// Attaches the Prometheus registry to an auditor built by
498    /// [`Auditor::with_resolver`].
499    ///
500    /// Exists for the test harness, which builds its auditor with a stub
501    /// resolver and still wants the counters. The serving path does **not** use
502    /// this — [`Auditor::from_config`] takes the registry as a parameter, so it
503    /// cannot be left off.
504    #[must_use]
505    pub fn with_metrics(mut self, metrics: Arc<crate::metrics::Metrics>) -> Self {
506        self.metrics = Some(metrics);
507        self
508    }
509
510    /// Writes one row, and counts it.
511    ///
512    /// The counter is driven off the *same* [`AuditRecord`] that is about to be
513    /// stored, which is what makes "how many certificates did we issue" answer
514    /// identically whether it is asked of the metrics endpoint or of
515    /// `acme-proxy audit list`. A second set of call sites incrementing
516    /// counters beside the audit writes would have been free to drift.
517    ///
518    /// See [`write()`], which this is the stateful spelling of.
519    pub async fn record(&self, record: AuditRecord) {
520        if let Some(metrics) = &self.metrics {
521            metrics.record_audit(&record);
522        }
523        write(record, &self.database).await;
524    }
525}
526
527/// Writes one row against a bare database handle.
528///
529/// The free function exists for the `relay` backend: it settles an issuance
530/// from a background task that holds an `Arc<Database>` and no [`Auditor`], and
531/// it needs no reverse lookup either — the address it records was resolved
532/// during the finalize request and stored on the `upstream_orders` row. Giving
533/// that task an `Auditor` would have meant threading one through
534/// `signer::build_backends` and `Profile::build_all` for the sake of a resolver
535/// it would never call.
536///
537/// **A failed write is logged and swallowed.** The alternative — failing the
538/// request — would turn a certificate this CA has already signed into a 500 the
539/// client retries, issuing a second one, which is a worse outcome for the same
540/// underlying fault. It is also nearly unreachable in practice: this is the
541/// same SQLite file the order was just written to, so a failure here means the
542/// write that preceded it had already failed. The `error!` carries the record's
543/// identifying fields, so the trail survives in the log even when the table did
544/// not get it.
545pub async fn write(record: AuditRecord, database: &Database) {
546    let (event, profile) = (record.event, record.profile.clone());
547    let (order_id, serial) = (record.order_id.clone(), record.cert_serial.clone());
548    if let Err(error) = AuditEntry::insert(record, database).await {
549        error!(
550            event = "audit_write_failed",
551            outcome = "failure",
552            audit_event = event.as_str(),
553            profile = %profile,
554            order_id = ?order_id,
555            cert_serial = ?serial,
556            error = %error,
557            "the action succeeded but its audit row was not written"
558        );
559    }
560}
561
562impl std::fmt::Debug for Auditor {
563    /// Renders the configured policy. `dyn Resolver` is not `Debug`, so the
564    /// resolver shows as whether there is one — which is the whole of what it
565    /// contributes to behaviour here.
566    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567        f.debug_struct("Auditor")
568            .field("reverse_dns", &self.resolver.is_some())
569            .field("ptr_timeout", &self.ptr_timeout)
570            .finish()
571    }
572}
573
574#[cfg(test)]
575mod tests;