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;