Skip to main content

acme_proxy_core/audit/
mod.rs

1//! The vocabulary of the CA's audit trail: who asked this server to sign or
2//! withdraw a certificate, from where, and how it ended.
3//!
4//! [`AuditEvent`], [`Actor`], [`ClientContext`], [`RequestContext`] and
5//! [`AuditRecord`] are what every call site builds a row from. Writing one, and
6//! the reverse lookup that fills in a client's name, are
7//! `auditor`'s: the vocabulary sits below the storage layer, which
8//! stores these very types, while the writer sits above it.
9//!
10//! ## Why this is not `notify`
11//!
12//! The two fire at nearly the same call sites and carry nearly the same fields,
13//! which invites merging them. They answer different questions. A notification
14//! is *outbound and lossy*: it goes to a chat room, it is fire-and-forget, and a
15//! backend that is down loses the event with a warning. An audit row is
16//! *inbound and durable*: it is the record the CA is answerable for, it is
17//! queried months later by serial or by account, and it exists for the events
18//! nobody wants a notification about — the refusals. Notifications also fire for
19//! things that never touch the CA (`account_created`, `challenge_failed`), and
20//! the audit trail records things nothing is notified about. Sharing a type
21//! would mean every future field arguing about which of the two it is for.
22
23use std::net::IpAddr;
24
25use axum::extract::FromRequestParts;
26use axum::http::request::Parts;
27
28/// What this trail records: every action the CA takes on a certificate and its
29/// refusal, plus every administrative action taken on the CA itself.
30///
31/// A refusal is an audit record in its own right. "Who tried to revoke this
32/// certificate and was turned away" is the question the successes cannot
33/// answer, and it is the one asked after something has gone wrong.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AuditEvent {
36    // The CA acting on a certificate, and each way that is refused. These four
37    // are also emitted as `tracing` events under the same names; the rest of
38    // the vocabulary below is not, and `as_str` is its own authority.
39    CertificateIssued,
40    CertificateIssueFailed,
41    CertificateRevoked,
42    CertificateRevokeFailed,
43    // The administration of the CA: an operator (or the host CLI) changing a
44    // stored account, credential, operator, session or queue entry. Recorded
45    // only on success — a refusal here is the operator being told the state of
46    // things, not the CA turning a remote party away. All map to `"success"`;
47    // the `outcome` match is deliberately exhaustive so a future `*_failed`
48    // admin event cannot be added without classifying it.
49    AccountDeactivated,
50    AccountContactUpdated,
51    AccountDeleted,
52    OrderDeleted,
53    EabCreated,
54    EabRevoked,
55    EabDeleted,
56    OperatorCreated,
57    OperatorRoleChanged,
58    OperatorContactUpdated,
59    OperatorPasswordChanged,
60    OperatorDisabled,
61    OperatorEnabled,
62    OperatorDeleted,
63    OperatorTotpEnrolled,
64    OperatorTotpDisabled,
65    OperatorRecoveryCodesRegenerated,
66    SessionRevoked,
67    JobCancelled,
68    JobAdvanced,
69    NonceCleanupCompleted,
70    AuditPruned,
71    DatabaseTransferred,
72}
73
74impl AuditEvent {
75    /// The stored form. This is the authority on the `audit_log.event`
76    /// vocabulary: `20260809120000_add_audit_log.sql`'s `CHECK (event IN (…))`
77    /// was dropped by a later rebuild precisely so this enum is the only place
78    /// the set is defined, and `AuditEntry::insert` binds this, never a free
79    /// string.
80    #[must_use]
81    pub fn as_str(&self) -> &'static str {
82        match self {
83            Self::CertificateIssued => "certificate_issued",
84            Self::CertificateIssueFailed => "certificate_issue_failed",
85            Self::CertificateRevoked => "certificate_revoked",
86            Self::CertificateRevokeFailed => "certificate_revoke_failed",
87            Self::AccountDeactivated => "account_deactivated",
88            Self::AccountContactUpdated => "account_contact_updated",
89            Self::AccountDeleted => "account_deleted",
90            Self::OrderDeleted => "order_deleted",
91            Self::EabCreated => "eab_created",
92            Self::EabRevoked => "eab_revoked",
93            Self::EabDeleted => "eab_deleted",
94            Self::OperatorCreated => "operator_created",
95            Self::OperatorRoleChanged => "operator_role_changed",
96            Self::OperatorContactUpdated => "operator_contact_updated",
97            Self::OperatorPasswordChanged => "operator_password_changed",
98            Self::OperatorDisabled => "operator_disabled",
99            Self::OperatorEnabled => "operator_enabled",
100            Self::OperatorDeleted => "operator_deleted",
101            Self::OperatorTotpEnrolled => "operator_totp_enrolled",
102            Self::OperatorTotpDisabled => "operator_totp_disabled",
103            Self::OperatorRecoveryCodesRegenerated => "operator_recovery_codes_regenerated",
104            Self::SessionRevoked => "session_revoked",
105            Self::JobCancelled => "job_cancelled",
106            Self::JobAdvanced => "job_advanced",
107            Self::NonceCleanupCompleted => "nonce_cleanup_completed",
108            Self::AuditPruned => "audit_pruned",
109            Self::DatabaseTransferred => "database_transferred",
110        }
111    }
112
113    /// `success` or `failure`, and the **only** definition of which is which.
114    ///
115    /// The column exists so "show me everything that was refused" is an index
116    /// lookup rather than `event LIKE '%_failed'` written out in the CLI, the
117    /// API and the page. Deriving it here rather than at each insert is what
118    /// stops the two columns ever disagreeing. Exhaustive on purpose — no
119    /// catch-all — so an admin `*_failed` event added later is a compile error
120    /// until its author says which side it falls on.
121    #[must_use]
122    pub fn outcome(&self) -> &'static str {
123        match self {
124            Self::CertificateIssueFailed | Self::CertificateRevokeFailed => "failure",
125            Self::CertificateIssued
126            | Self::CertificateRevoked
127            | Self::AccountDeactivated
128            | Self::AccountContactUpdated
129            | Self::AccountDeleted
130            | Self::OrderDeleted
131            | Self::EabCreated
132            | Self::EabRevoked
133            | Self::EabDeleted
134            | Self::OperatorCreated
135            | Self::OperatorRoleChanged
136            | Self::OperatorContactUpdated
137            | Self::OperatorPasswordChanged
138            | Self::OperatorDisabled
139            | Self::OperatorEnabled
140            | Self::OperatorDeleted
141            | Self::OperatorTotpEnrolled
142            | Self::OperatorTotpDisabled
143            | Self::OperatorRecoveryCodesRegenerated
144            | Self::SessionRevoked
145            | Self::JobCancelled
146            | Self::JobAdvanced
147            | Self::NonceCleanupCompleted
148            | Self::AuditPruned
149            | Self::DatabaseTransferred => "success",
150        }
151    }
152
153    /// Parses the stored form back. `None` for anything this enum does not
154    /// define, which is also how the CLI validates `--event`.
155    #[must_use]
156    pub fn parse(value: &str) -> Option<Self> {
157        ALL_AUDIT_EVENTS
158            .iter()
159            .copied()
160            .find(|event| event.as_str() == value)
161    }
162}
163
164/// Every [`AuditEvent`], for the CLI's `--event` help text and the page's filter.
165///
166/// **[`AuditEvent::parse`] is implemented over this array**, so a variant added
167/// to the enum and forgotten here does not merely go unlisted: it stops
168/// parsing, for ever, which means `audit list --event <name>` refuses a name the
169/// server is actively writing and the page's filter cannot select it. Nothing
170/// would fail — the round-trip test iterates *this* array, so it would be
171/// vacuously satisfied.
172///
173/// `EVENT_COUNT` plus the exhaustive `match` in `event_count_is_exhaustive`
174/// below is the guard: adding a variant is a compile error until both move.
175/// `ALL_NOTIFY_EVENTS` keeps the same kind of assertion for the same reason.
176pub const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
177    AuditEvent::CertificateIssued,
178    AuditEvent::CertificateIssueFailed,
179    AuditEvent::CertificateRevoked,
180    AuditEvent::CertificateRevokeFailed,
181    AuditEvent::AccountDeactivated,
182    AuditEvent::AccountContactUpdated,
183    AuditEvent::AccountDeleted,
184    AuditEvent::OrderDeleted,
185    AuditEvent::EabCreated,
186    AuditEvent::EabRevoked,
187    AuditEvent::EabDeleted,
188    AuditEvent::OperatorCreated,
189    AuditEvent::OperatorRoleChanged,
190    AuditEvent::OperatorContactUpdated,
191    AuditEvent::OperatorPasswordChanged,
192    AuditEvent::OperatorDisabled,
193    AuditEvent::OperatorEnabled,
194    AuditEvent::OperatorDeleted,
195    AuditEvent::OperatorTotpEnrolled,
196    AuditEvent::OperatorTotpDisabled,
197    AuditEvent::OperatorRecoveryCodesRegenerated,
198    AuditEvent::SessionRevoked,
199    AuditEvent::JobCancelled,
200    AuditEvent::JobAdvanced,
201    AuditEvent::NonceCleanupCompleted,
202    AuditEvent::AuditPruned,
203    AuditEvent::DatabaseTransferred,
204];
205
206/// How many variants [`AuditEvent`] has, asserted against
207/// [`ALL_AUDIT_EVENTS`] at compile time.
208const EVENT_COUNT: usize = 27;
209
210const _: () = assert!(
211    ALL_AUDIT_EVENTS.len() == EVENT_COUNT,
212    "ALL_AUDIT_EVENTS and EVENT_COUNT disagree: a variant was added to one and not the other"
213);
214
215/// Ties [`EVENT_COUNT`] to the enum itself.
216///
217/// An exhaustive `match` with no catch-all, so a new variant is a compile error
218/// here; the arms count up to `EVENT_COUNT`, which the `const` assertion above
219/// ties back to [`ALL_AUDIT_EVENTS`]. Between them, a variant cannot reach
220/// production without appearing in the array [`AuditEvent::parse`] reads.
221#[allow(dead_code)]
222const fn event_count_is_exhaustive(event: AuditEvent) -> usize {
223    match event {
224        AuditEvent::CertificateIssued => 1,
225        AuditEvent::CertificateIssueFailed => 2,
226        AuditEvent::CertificateRevoked => 3,
227        AuditEvent::CertificateRevokeFailed => 4,
228        AuditEvent::AccountDeactivated => 5,
229        AuditEvent::AccountContactUpdated => 6,
230        AuditEvent::AccountDeleted => 7,
231        AuditEvent::OrderDeleted => 8,
232        AuditEvent::EabCreated => 9,
233        AuditEvent::EabRevoked => 10,
234        AuditEvent::EabDeleted => 11,
235        AuditEvent::OperatorCreated => 12,
236        AuditEvent::OperatorRoleChanged => 13,
237        AuditEvent::OperatorContactUpdated => 14,
238        AuditEvent::OperatorPasswordChanged => 15,
239        AuditEvent::OperatorDisabled => 16,
240        AuditEvent::OperatorEnabled => 17,
241        AuditEvent::OperatorDeleted => 18,
242        AuditEvent::OperatorTotpEnrolled => 19,
243        AuditEvent::OperatorTotpDisabled => 20,
244        AuditEvent::OperatorRecoveryCodesRegenerated => 21,
245        AuditEvent::SessionRevoked => 22,
246        AuditEvent::JobCancelled => 23,
247        AuditEvent::JobAdvanced => 24,
248        AuditEvent::NonceCleanupCompleted => 25,
249        AuditEvent::AuditPruned => 26,
250        AuditEvent::DatabaseTransferred => EVENT_COUNT,
251    }
252}
253
254/// Which front end acted.
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum ActorKind {
257    /// A certificate client over the ACME API.
258    Acme,
259    /// An operator through the web admin.
260    Admin,
261    /// `acme-proxy order revoke` on the host.
262    Cli,
263    /// The `relay` signer's background task, settling an issuance this
264    /// server already answered `processing`. The one actor with no request
265    /// behind it, and therefore no address — see [`Actor::system`].
266    System,
267}
268
269impl ActorKind {
270    /// The spelling stored in `audit_log.actor_kind`.
271    #[must_use]
272    pub fn as_str(&self) -> &'static str {
273        match self {
274            Self::Acme => "acme",
275            Self::Admin => "admin",
276            Self::Cli => "cli",
277            Self::System => "system",
278        }
279    }
280}
281
282/// Who acted, and their identity within that kind.
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct Actor {
285    pub kind: ActorKind,
286    pub id: Option<String>,
287}
288
289impl Actor {
290    /// An ACME client acting as a known account.
291    #[must_use]
292    pub fn acme(account_id: impl Into<String>) -> Self {
293        Self {
294            kind: ActorKind::Acme,
295            id: Some(account_id.into()),
296        }
297    }
298
299    /// An ACME client that proved possession of the certificate's own key pair
300    /// and named no account — RFC 8555 §7.6's accountless revocation.
301    ///
302    /// The `None` is the honest answer and not a gap: there is no identity to
303    /// record beyond "whoever holds this certificate's private key", which the
304    /// `cert_serial` on the same row already says.
305    #[must_use]
306    pub fn acme_certificate_key() -> Self {
307        Self {
308            kind: ActorKind::Acme,
309            id: None,
310        }
311    }
312
313    /// An operator signed in to the web admin.
314    #[must_use]
315    pub fn admin(username: impl Into<String>) -> Self {
316        Self {
317            kind: ActorKind::Admin,
318            id: Some(username.into()),
319        }
320    }
321
322    /// The command line, identified by whichever of `$USER`/`$LOGNAME` is set.
323    ///
324    /// Advisory only, and unavoidably so: anything running this binary can set
325    /// those variables. It narrows "somebody on the host" to "somebody on the
326    /// host, probably this account", which is the most a process can say about
327    /// its own invoker without help from the audit subsystem of the OS.
328    #[must_use]
329    pub fn cli() -> Self {
330        let id = std::env::var("USER")
331            .or_else(|_| std::env::var("LOGNAME"))
332            .ok()
333            .filter(|value| !value.is_empty());
334        Self {
335            kind: ActorKind::Cli,
336            id,
337        }
338    }
339
340    /// This server's own background work.
341    #[must_use]
342    pub fn system() -> Self {
343        Self {
344            kind: ActorKind::System,
345            id: None,
346        }
347    }
348}
349
350/// The request a row came from: address, its reverse name, and the two headers
351/// worth keeping.
352///
353/// Entirely empty for [`ActorKind::Cli`] and [`ActorKind::System`], which is
354/// why every field is optional rather than a placeholder string — "there was no
355/// client" and "the client sent no User-Agent" are both `None`, and the
356/// `actor_kind` on the row already tells them apart.
357#[derive(Debug, Clone, Default, PartialEq, Eq)]
358pub struct ClientContext {
359    pub ip: Option<String>,
360    pub ptr: Option<String>,
361    pub user_agent: Option<String>,
362    pub request_id: Option<String>,
363}
364
365impl ClientContext {
366    /// This context as a job payload member, so a row written by work a
367    /// request queued — an issuance, a revocation — can still name the client
368    /// that asked. An absent field is `null`, as `None` is on the row.
369    #[must_use]
370    pub fn to_json(&self) -> serde_json::Value {
371        serde_json::json!({
372            "ip": self.ip,
373            "ptr": self.ptr,
374            "user_agent": self.user_agent,
375            "request_id": self.request_id,
376        })
377    }
378
379    /// The context [`to_json`](Self::to_json) wrote. Anything missing or not a
380    /// string reads as absent, so a payload written before a field existed
381    /// still yields a context rather than an error.
382    #[must_use]
383    pub fn from_json(value: &serde_json::Value) -> Self {
384        let field = |name: &str| value.get(name).and_then(|v| v.as_str()).map(str::to_string);
385        Self {
386            ip: field("ip"),
387            ptr: field("ptr"),
388            user_agent: field("user_agent"),
389            request_id: field("request_id"),
390        }
391    }
392}
393
394/// What a request carries before the reverse lookup has run.
395///
396/// An extractor rather than three `Extension`s at each call site: a handler
397/// that records an audit row wants all of this or none of it, and gathering it
398/// in one place is what keeps `User-Agent`'s truncation rule (below) from being
399/// re-decided per handler. Resolve it into a [`ClientContext`] with
400/// `Auditor::client`.
401#[derive(Debug, Clone, Default)]
402pub struct RequestContext {
403    pub ip: Option<IpAddr>,
404    pub user_agent: Option<String>,
405    pub request_id: Option<String>,
406}
407
408/// Longest `User-Agent` kept. Real ones are well under this; the header is
409/// attacker-controlled and ends up in a database column and an HTML page, so it
410/// gets a ceiling rather than trust.
411///
412/// Public because the web admin's sign-in path (`webadmin::user_agent_of`)
413/// caps the *same* header on the way into a notification payload, and
414/// `middlewares::access` borrows the reasoning for `x-request-id`. One
415/// constant, so the answers to "how much of this do we keep?" cannot drift.
416pub const USER_AGENT_MAX: usize = 256;
417
418impl RequestContext {
419    /// Reads the address the filter middleware resolved, plus the two headers.
420    ///
421    /// Free of the request body, so this composes with `AcmeRequest<T>` — which
422    /// consumes it — in the usual axum order.
423    pub fn from_parts(parts: &Parts) -> Self {
424        Self::gather(&parts.headers, &parts.extensions)
425    }
426
427    /// Same, from a whole request.
428    ///
429    /// `verify_jws` needs this: it is handed the `Request` and consumes it into
430    /// a body string, so it has to read the context *before* the point where
431    /// the extractor machinery would hand it `Parts`.
432    pub fn from_request<B>(request: &axum::http::Request<B>) -> Self {
433        Self::gather(request.headers(), request.extensions())
434    }
435
436    fn gather(headers: &axum::http::HeaderMap, extensions: &axum::http::Extensions) -> Self {
437        let ip = extensions
438            .get::<crate::client::ClientIp>()
439            .and_then(|client| client.0);
440        let user_agent = headers
441            .get(axum::http::header::USER_AGENT)
442            .and_then(|value| value.to_str().ok())
443            .map(|value| value.chars().take(USER_AGENT_MAX).collect::<String>())
444            .filter(|value| !value.is_empty());
445        let request_id = extensions
446            .get::<crate::client::RequestId>()
447            .map(|id| id.0.clone());
448        Self {
449            ip,
450            user_agent,
451            request_id,
452        }
453    }
454}
455
456impl<S: Send + Sync> FromRequestParts<S> for RequestContext {
457    type Rejection = std::convert::Infallible;
458
459    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
460        Ok(Self::from_parts(parts))
461    }
462}
463
464/// One row, before it is written.
465///
466/// Built with [`AuditRecord::new`] plus the `with_*` setters rather than a
467/// struct literal: the four events populate different subsets — an issuance
468/// failure has no serial, an accountless revocation has no account — and a
469/// literal would mean a column of `None`s at every call site.
470#[derive(Debug, Clone)]
471pub struct AuditRecord {
472    pub event: AuditEvent,
473    pub profile: String,
474    pub actor: Actor,
475    pub account_id: Option<String>,
476    pub order_id: Option<String>,
477    pub cert_serial: Option<String>,
478    pub identifiers: Vec<String>,
479    pub client: ClientContext,
480    pub reason: Option<String>,
481    pub detail: Option<String>,
482}
483
484impl AuditRecord {
485    /// A record of `event` at `profile`, by `actor`, with every optional field
486    /// empty; the `with_*` builders fill them in.
487    #[must_use]
488    pub fn new(event: AuditEvent, profile: impl Into<String>, actor: Actor) -> Self {
489        Self {
490            event,
491            profile: profile.into(),
492            actor,
493            account_id: None,
494            order_id: None,
495            cert_serial: None,
496            identifiers: Vec::new(),
497            client: ClientContext::default(),
498            reason: None,
499            detail: None,
500        }
501    }
502
503    /// A record for an administrative action that is not scoped to one ACME
504    /// endpoint — an operator, a session, the nonce table, the audit log
505    /// itself. `profile` is stored empty (the column stays `NOT NULL`; `""`
506    /// reads as "no profile"), and the acting identity is [`Actor`] plus
507    /// whatever [`AuditRecord::with_detail`] carries. Account, EAB and order
508    /// actions keep [`AuditRecord::new`] with their real profile.
509    #[must_use]
510    pub fn admin(event: AuditEvent, actor: Actor) -> Self {
511        Self::new(event, String::new(), actor)
512    }
513
514    /// Fills in the subject from an order: its id, its account and the names
515    /// it covers, the last frozen into the row rather than joined back — the
516    /// order may be deleted long before the row is read.
517    ///
518    /// Takes the three fields rather than the stored order, which is a row of
519    /// the storage layer this vocabulary sits below.
520    #[must_use]
521    pub fn with_order(
522        mut self,
523        order_id: uuid::Uuid,
524        account_id: uuid::Uuid,
525        identifiers: &[crate::identifier::Identifier],
526    ) -> Self {
527        self.order_id = Some(order_id.to_string());
528        self.account_id = Some(account_id.to_string());
529        self.identifiers = identifiers
530            .iter()
531            .map(|identifier| identifier.value.clone())
532            .collect();
533        self
534    }
535
536    /// The account the row is about.
537    #[must_use]
538    pub fn with_account(mut self, account_id: impl Into<String>) -> Self {
539        self.account_id = Some(account_id.into());
540        self
541    }
542
543    /// The certificate serial, lowercase unseparated hex.
544    #[must_use]
545    pub fn with_serial(mut self, serial: impl Into<String>) -> Self {
546        self.cert_serial = Some(serial.into());
547        self
548    }
549
550    /// Where the request came from: address, reverse name, `User-Agent` and
551    /// request id.
552    #[must_use]
553    pub fn with_client(mut self, client: ClientContext) -> Self {
554        self.client = client;
555        self
556    }
557
558    /// The RFC 8555 problem type on a refusal, or the RFC 5280 reason code on a
559    /// revocation. Never both — see the column comment in the migration.
560    #[must_use]
561    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
562        self.reason = Some(reason.into());
563        self
564    }
565
566    /// Free text for a human reading the trail. Never parsed.
567    #[must_use]
568    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
569        self.detail = Some(detail.into());
570        self
571    }
572}
573
574#[cfg(test)]
575mod tests;