acme-proxy-core 0.6.1

Configuration, ACME wire types and shared vocabulary for acme-proxy (internal crate, no semver promise)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
//! The vocabulary of the CA's audit trail: who asked this server to sign or
//! withdraw a certificate, from where, and how it ended.
//!
//! [`AuditEvent`], [`Actor`], [`ClientContext`], [`RequestContext`] and
//! [`AuditRecord`] are what every call site builds a row from. Writing one, and
//! the reverse lookup that fills in a client's name, are
//! `auditor`'s: the vocabulary sits below the storage layer, which
//! stores these very types, while the writer sits above it.
//!
//! ## Why this is not `notify`
//!
//! The two fire at nearly the same call sites and carry nearly the same fields,
//! which invites merging them. They answer different questions. A notification
//! is *outbound and lossy*: it goes to a chat room, it is fire-and-forget, and a
//! backend that is down loses the event with a warning. An audit row is
//! *inbound and durable*: it is the record the CA is answerable for, it is
//! queried months later by serial or by account, and it exists for the events
//! nobody wants a notification about — the refusals. Notifications also fire for
//! things that never touch the CA (`account_created`, `challenge_failed`), and
//! the audit trail records things nothing is notified about. Sharing a type
//! would mean every future field arguing about which of the two it is for.

use std::net::IpAddr;

use axum::extract::FromRequestParts;
use axum::http::request::Parts;

/// What this trail records: every action the CA takes on a certificate and its
/// refusal, plus every administrative action taken on the CA itself.
///
/// A refusal is an audit record in its own right. "Who tried to revoke this
/// certificate and was turned away" is the question the successes cannot
/// answer, and it is the one asked after something has gone wrong.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditEvent {
    // The CA acting on a certificate, and each way that is refused. These four
    // are also emitted as `tracing` events under the same names; the rest of
    // the vocabulary below is not, and `as_str` is its own authority.
    CertificateIssued,
    CertificateIssueFailed,
    CertificateRevoked,
    CertificateRevokeFailed,
    // The administration of the CA: an operator (or the host CLI) changing a
    // stored account, credential, operator, session or queue entry. Recorded
    // only on success — a refusal here is the operator being told the state of
    // things, not the CA turning a remote party away. All map to `"success"`;
    // the `outcome` match is deliberately exhaustive so a future `*_failed`
    // admin event cannot be added without classifying it.
    AccountDeactivated,
    AccountContactUpdated,
    AccountDeleted,
    OrderDeleted,
    EabCreated,
    EabRevoked,
    EabDeleted,
    OperatorCreated,
    OperatorRoleChanged,
    OperatorContactUpdated,
    OperatorPasswordChanged,
    OperatorDisabled,
    OperatorEnabled,
    OperatorDeleted,
    OperatorTotpEnrolled,
    OperatorTotpDisabled,
    OperatorRecoveryCodesRegenerated,
    SessionRevoked,
    JobCancelled,
    JobAdvanced,
    NonceCleanupCompleted,
    AuditPruned,
    DatabaseTransferred,
}

impl AuditEvent {
    /// The stored form. This is the authority on the `audit_log.event`
    /// vocabulary: `20260809120000_add_audit_log.sql`'s `CHECK (event IN (…))`
    /// was dropped by a later rebuild precisely so this enum is the only place
    /// the set is defined, and `AuditEntry::insert` binds this, never a free
    /// string.
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::CertificateIssued => "certificate_issued",
            Self::CertificateIssueFailed => "certificate_issue_failed",
            Self::CertificateRevoked => "certificate_revoked",
            Self::CertificateRevokeFailed => "certificate_revoke_failed",
            Self::AccountDeactivated => "account_deactivated",
            Self::AccountContactUpdated => "account_contact_updated",
            Self::AccountDeleted => "account_deleted",
            Self::OrderDeleted => "order_deleted",
            Self::EabCreated => "eab_created",
            Self::EabRevoked => "eab_revoked",
            Self::EabDeleted => "eab_deleted",
            Self::OperatorCreated => "operator_created",
            Self::OperatorRoleChanged => "operator_role_changed",
            Self::OperatorContactUpdated => "operator_contact_updated",
            Self::OperatorPasswordChanged => "operator_password_changed",
            Self::OperatorDisabled => "operator_disabled",
            Self::OperatorEnabled => "operator_enabled",
            Self::OperatorDeleted => "operator_deleted",
            Self::OperatorTotpEnrolled => "operator_totp_enrolled",
            Self::OperatorTotpDisabled => "operator_totp_disabled",
            Self::OperatorRecoveryCodesRegenerated => "operator_recovery_codes_regenerated",
            Self::SessionRevoked => "session_revoked",
            Self::JobCancelled => "job_cancelled",
            Self::JobAdvanced => "job_advanced",
            Self::NonceCleanupCompleted => "nonce_cleanup_completed",
            Self::AuditPruned => "audit_pruned",
            Self::DatabaseTransferred => "database_transferred",
        }
    }

    /// `success` or `failure`, and the **only** definition of which is which.
    ///
    /// The column exists so "show me everything that was refused" is an index
    /// lookup rather than `event LIKE '%_failed'` written out in the CLI, the
    /// API and the page. Deriving it here rather than at each insert is what
    /// stops the two columns ever disagreeing. Exhaustive on purpose — no
    /// catch-all — so an admin `*_failed` event added later is a compile error
    /// until its author says which side it falls on.
    #[must_use]
    pub fn outcome(&self) -> &'static str {
        match self {
            Self::CertificateIssueFailed | Self::CertificateRevokeFailed => "failure",
            Self::CertificateIssued
            | Self::CertificateRevoked
            | Self::AccountDeactivated
            | Self::AccountContactUpdated
            | Self::AccountDeleted
            | Self::OrderDeleted
            | Self::EabCreated
            | Self::EabRevoked
            | Self::EabDeleted
            | Self::OperatorCreated
            | Self::OperatorRoleChanged
            | Self::OperatorContactUpdated
            | Self::OperatorPasswordChanged
            | Self::OperatorDisabled
            | Self::OperatorEnabled
            | Self::OperatorDeleted
            | Self::OperatorTotpEnrolled
            | Self::OperatorTotpDisabled
            | Self::OperatorRecoveryCodesRegenerated
            | Self::SessionRevoked
            | Self::JobCancelled
            | Self::JobAdvanced
            | Self::NonceCleanupCompleted
            | Self::AuditPruned
            | Self::DatabaseTransferred => "success",
        }
    }

    /// Parses the stored form back. `None` for anything this enum does not
    /// define, which is also how the CLI validates `--event`.
    #[must_use]
    pub fn parse(value: &str) -> Option<Self> {
        ALL_AUDIT_EVENTS
            .iter()
            .copied()
            .find(|event| event.as_str() == value)
    }
}

/// Every [`AuditEvent`], for the CLI's `--event` help text and the page's filter.
///
/// **[`AuditEvent::parse`] is implemented over this array**, so a variant added
/// to the enum and forgotten here does not merely go unlisted: it stops
/// parsing, for ever, which means `audit list --event <name>` refuses a name the
/// server is actively writing and the page's filter cannot select it. Nothing
/// would fail — the round-trip test iterates *this* array, so it would be
/// vacuously satisfied.
///
/// `EVENT_COUNT` plus the exhaustive `match` in `event_count_is_exhaustive`
/// below is the guard: adding a variant is a compile error until both move.
/// `ALL_NOTIFY_EVENTS` keeps the same kind of assertion for the same reason.
pub const ALL_AUDIT_EVENTS: &[AuditEvent] = &[
    AuditEvent::CertificateIssued,
    AuditEvent::CertificateIssueFailed,
    AuditEvent::CertificateRevoked,
    AuditEvent::CertificateRevokeFailed,
    AuditEvent::AccountDeactivated,
    AuditEvent::AccountContactUpdated,
    AuditEvent::AccountDeleted,
    AuditEvent::OrderDeleted,
    AuditEvent::EabCreated,
    AuditEvent::EabRevoked,
    AuditEvent::EabDeleted,
    AuditEvent::OperatorCreated,
    AuditEvent::OperatorRoleChanged,
    AuditEvent::OperatorContactUpdated,
    AuditEvent::OperatorPasswordChanged,
    AuditEvent::OperatorDisabled,
    AuditEvent::OperatorEnabled,
    AuditEvent::OperatorDeleted,
    AuditEvent::OperatorTotpEnrolled,
    AuditEvent::OperatorTotpDisabled,
    AuditEvent::OperatorRecoveryCodesRegenerated,
    AuditEvent::SessionRevoked,
    AuditEvent::JobCancelled,
    AuditEvent::JobAdvanced,
    AuditEvent::NonceCleanupCompleted,
    AuditEvent::AuditPruned,
    AuditEvent::DatabaseTransferred,
];

/// How many variants [`AuditEvent`] has, asserted against
/// [`ALL_AUDIT_EVENTS`] at compile time.
const EVENT_COUNT: usize = 27;

const _: () = assert!(
    ALL_AUDIT_EVENTS.len() == EVENT_COUNT,
    "ALL_AUDIT_EVENTS and EVENT_COUNT disagree: a variant was added to one and not the other"
);

/// Ties [`EVENT_COUNT`] to the enum itself.
///
/// An exhaustive `match` with no catch-all, so a new variant is a compile error
/// here; the arms count up to `EVENT_COUNT`, which the `const` assertion above
/// ties back to [`ALL_AUDIT_EVENTS`]. Between them, a variant cannot reach
/// production without appearing in the array [`AuditEvent::parse`] reads.
#[allow(dead_code)]
const fn event_count_is_exhaustive(event: AuditEvent) -> usize {
    match event {
        AuditEvent::CertificateIssued => 1,
        AuditEvent::CertificateIssueFailed => 2,
        AuditEvent::CertificateRevoked => 3,
        AuditEvent::CertificateRevokeFailed => 4,
        AuditEvent::AccountDeactivated => 5,
        AuditEvent::AccountContactUpdated => 6,
        AuditEvent::AccountDeleted => 7,
        AuditEvent::OrderDeleted => 8,
        AuditEvent::EabCreated => 9,
        AuditEvent::EabRevoked => 10,
        AuditEvent::EabDeleted => 11,
        AuditEvent::OperatorCreated => 12,
        AuditEvent::OperatorRoleChanged => 13,
        AuditEvent::OperatorContactUpdated => 14,
        AuditEvent::OperatorPasswordChanged => 15,
        AuditEvent::OperatorDisabled => 16,
        AuditEvent::OperatorEnabled => 17,
        AuditEvent::OperatorDeleted => 18,
        AuditEvent::OperatorTotpEnrolled => 19,
        AuditEvent::OperatorTotpDisabled => 20,
        AuditEvent::OperatorRecoveryCodesRegenerated => 21,
        AuditEvent::SessionRevoked => 22,
        AuditEvent::JobCancelled => 23,
        AuditEvent::JobAdvanced => 24,
        AuditEvent::NonceCleanupCompleted => 25,
        AuditEvent::AuditPruned => 26,
        AuditEvent::DatabaseTransferred => EVENT_COUNT,
    }
}

/// Which front end acted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorKind {
    /// A certificate client over the ACME API.
    Acme,
    /// An operator through the web admin.
    Admin,
    /// `acme-proxy order revoke` on the host.
    Cli,
    /// The `relay` signer's background task, settling an issuance this
    /// server already answered `processing`. The one actor with no request
    /// behind it, and therefore no address — see [`Actor::system`].
    System,
}

impl ActorKind {
    /// The spelling stored in `audit_log.actor_kind`.
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Acme => "acme",
            Self::Admin => "admin",
            Self::Cli => "cli",
            Self::System => "system",
        }
    }
}

/// Who acted, and their identity within that kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Actor {
    pub kind: ActorKind,
    pub id: Option<String>,
}

impl Actor {
    /// An ACME client acting as a known account.
    #[must_use]
    pub fn acme(account_id: impl Into<String>) -> Self {
        Self {
            kind: ActorKind::Acme,
            id: Some(account_id.into()),
        }
    }

    /// An ACME client that proved possession of the certificate's own key pair
    /// and named no account — RFC 8555 §7.6's accountless revocation.
    ///
    /// The `None` is the honest answer and not a gap: there is no identity to
    /// record beyond "whoever holds this certificate's private key", which the
    /// `cert_serial` on the same row already says.
    #[must_use]
    pub fn acme_certificate_key() -> Self {
        Self {
            kind: ActorKind::Acme,
            id: None,
        }
    }

    /// An operator signed in to the web admin.
    #[must_use]
    pub fn admin(username: impl Into<String>) -> Self {
        Self {
            kind: ActorKind::Admin,
            id: Some(username.into()),
        }
    }

    /// The command line, identified by whichever of `$USER`/`$LOGNAME` is set.
    ///
    /// Advisory only, and unavoidably so: anything running this binary can set
    /// those variables. It narrows "somebody on the host" to "somebody on the
    /// host, probably this account", which is the most a process can say about
    /// its own invoker without help from the audit subsystem of the OS.
    #[must_use]
    pub fn cli() -> Self {
        let id = std::env::var("USER")
            .or_else(|_| std::env::var("LOGNAME"))
            .ok()
            .filter(|value| !value.is_empty());
        Self {
            kind: ActorKind::Cli,
            id,
        }
    }

    /// This server's own background work.
    #[must_use]
    pub fn system() -> Self {
        Self {
            kind: ActorKind::System,
            id: None,
        }
    }
}

/// The request a row came from: address, its reverse name, and the two headers
/// worth keeping.
///
/// Entirely empty for [`ActorKind::Cli`] and [`ActorKind::System`], which is
/// why every field is optional rather than a placeholder string — "there was no
/// client" and "the client sent no User-Agent" are both `None`, and the
/// `actor_kind` on the row already tells them apart.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClientContext {
    pub ip: Option<String>,
    pub ptr: Option<String>,
    pub user_agent: Option<String>,
    pub request_id: Option<String>,
}

impl ClientContext {
    /// This context as a job payload member, so a row written by work a
    /// request queued — an issuance, a revocation — can still name the client
    /// that asked. An absent field is `null`, as `None` is on the row.
    #[must_use]
    pub fn to_json(&self) -> serde_json::Value {
        serde_json::json!({
            "ip": self.ip,
            "ptr": self.ptr,
            "user_agent": self.user_agent,
            "request_id": self.request_id,
        })
    }

    /// The context [`to_json`](Self::to_json) wrote. Anything missing or not a
    /// string reads as absent, so a payload written before a field existed
    /// still yields a context rather than an error.
    #[must_use]
    pub fn from_json(value: &serde_json::Value) -> Self {
        let field = |name: &str| value.get(name).and_then(|v| v.as_str()).map(str::to_string);
        Self {
            ip: field("ip"),
            ptr: field("ptr"),
            user_agent: field("user_agent"),
            request_id: field("request_id"),
        }
    }
}

/// What a request carries before the reverse lookup has run.
///
/// An extractor rather than three `Extension`s at each call site: a handler
/// that records an audit row wants all of this or none of it, and gathering it
/// in one place is what keeps `User-Agent`'s truncation rule (below) from being
/// re-decided per handler. Resolve it into a [`ClientContext`] with
/// `Auditor::client`.
#[derive(Debug, Clone, Default)]
pub struct RequestContext {
    pub ip: Option<IpAddr>,
    pub user_agent: Option<String>,
    pub request_id: Option<String>,
}

/// Longest `User-Agent` kept. Real ones are well under this; the header is
/// attacker-controlled and ends up in a database column and an HTML page, so it
/// gets a ceiling rather than trust.
///
/// Public because the web admin's sign-in path (`webadmin::user_agent_of`)
/// caps the *same* header on the way into a notification payload, and
/// `middlewares::access` borrows the reasoning for `x-request-id`. One
/// constant, so the answers to "how much of this do we keep?" cannot drift.
pub const USER_AGENT_MAX: usize = 256;

impl RequestContext {
    /// Reads the address the filter middleware resolved, plus the two headers.
    ///
    /// Free of the request body, so this composes with `AcmeRequest<T>` — which
    /// consumes it — in the usual axum order.
    pub fn from_parts(parts: &Parts) -> Self {
        Self::gather(&parts.headers, &parts.extensions)
    }

    /// Same, from a whole request.
    ///
    /// `verify_jws` needs this: it is handed the `Request` and consumes it into
    /// a body string, so it has to read the context *before* the point where
    /// the extractor machinery would hand it `Parts`.
    pub fn from_request<B>(request: &axum::http::Request<B>) -> Self {
        Self::gather(request.headers(), request.extensions())
    }

    fn gather(headers: &axum::http::HeaderMap, extensions: &axum::http::Extensions) -> Self {
        let ip = extensions
            .get::<crate::client::ClientIp>()
            .and_then(|client| client.0);
        let user_agent = headers
            .get(axum::http::header::USER_AGENT)
            .and_then(|value| value.to_str().ok())
            .map(|value| value.chars().take(USER_AGENT_MAX).collect::<String>())
            .filter(|value| !value.is_empty());
        let request_id = extensions
            .get::<crate::client::RequestId>()
            .map(|id| id.0.clone());
        Self {
            ip,
            user_agent,
            request_id,
        }
    }
}

impl<S: Send + Sync> FromRequestParts<S> for RequestContext {
    type Rejection = std::convert::Infallible;

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        Ok(Self::from_parts(parts))
    }
}

/// One row, before it is written.
///
/// Built with [`AuditRecord::new`] plus the `with_*` setters rather than a
/// struct literal: the four events populate different subsets — an issuance
/// failure has no serial, an accountless revocation has no account — and a
/// literal would mean a column of `None`s at every call site.
#[derive(Debug, Clone)]
pub struct AuditRecord {
    pub event: AuditEvent,
    pub profile: String,
    pub actor: Actor,
    pub account_id: Option<String>,
    pub order_id: Option<String>,
    pub cert_serial: Option<String>,
    pub identifiers: Vec<String>,
    pub client: ClientContext,
    pub reason: Option<String>,
    pub detail: Option<String>,
}

impl AuditRecord {
    /// A record of `event` at `profile`, by `actor`, with every optional field
    /// empty; the `with_*` builders fill them in.
    #[must_use]
    pub fn new(event: AuditEvent, profile: impl Into<String>, actor: Actor) -> Self {
        Self {
            event,
            profile: profile.into(),
            actor,
            account_id: None,
            order_id: None,
            cert_serial: None,
            identifiers: Vec::new(),
            client: ClientContext::default(),
            reason: None,
            detail: None,
        }
    }

    /// A record for an administrative action that is not scoped to one ACME
    /// endpoint — an operator, a session, the nonce table, the audit log
    /// itself. `profile` is stored empty (the column stays `NOT NULL`; `""`
    /// reads as "no profile"), and the acting identity is [`Actor`] plus
    /// whatever [`AuditRecord::with_detail`] carries. Account, EAB and order
    /// actions keep [`AuditRecord::new`] with their real profile.
    #[must_use]
    pub fn admin(event: AuditEvent, actor: Actor) -> Self {
        Self::new(event, String::new(), actor)
    }

    /// Fills in the subject from an order: its id, its account and the names
    /// it covers, the last frozen into the row rather than joined back — the
    /// order may be deleted long before the row is read.
    ///
    /// Takes the three fields rather than the stored order, which is a row of
    /// the storage layer this vocabulary sits below.
    #[must_use]
    pub fn with_order(
        mut self,
        order_id: uuid::Uuid,
        account_id: uuid::Uuid,
        identifiers: &[crate::identifier::Identifier],
    ) -> Self {
        self.order_id = Some(order_id.to_string());
        self.account_id = Some(account_id.to_string());
        self.identifiers = identifiers
            .iter()
            .map(|identifier| identifier.value.clone())
            .collect();
        self
    }

    /// The account the row is about.
    #[must_use]
    pub fn with_account(mut self, account_id: impl Into<String>) -> Self {
        self.account_id = Some(account_id.into());
        self
    }

    /// The certificate serial, lowercase unseparated hex.
    #[must_use]
    pub fn with_serial(mut self, serial: impl Into<String>) -> Self {
        self.cert_serial = Some(serial.into());
        self
    }

    /// Where the request came from: address, reverse name, `User-Agent` and
    /// request id.
    #[must_use]
    pub fn with_client(mut self, client: ClientContext) -> Self {
        self.client = client;
        self
    }

    /// The RFC 8555 problem type on a refusal, or the RFC 5280 reason code on a
    /// revocation. Never both — see the column comment in the migration.
    #[must_use]
    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
        self.reason = Some(reason.into());
        self
    }

    /// Free text for a human reading the trail. Never parsed.
    #[must_use]
    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
        self.detail = Some(detail.into());
        self
    }
}

#[cfg(test)]
mod tests;