Skip to main content

acme_proxy_protocol/acme/
revoke.rs

1//! Certificate revocation (RFC 8555 §7.6), whoever asks.
2//!
3//! Two front doors and one tail. A client asks by presenting the certificate
4//! and signing with a key that may revoke it; an operator asks by naming the
5//! order, and is trusted to. From "is it already revoked?" onwards the two are
6//! the same operation — the signer first, then the order row, then the trail
7//! and the notification — and that tail is written once, here.
8//!
9//! **No request reaches a backend.** Withdrawing trust needs what only the
10//! `worker` role holds — a CA key, a token login, an upstream account — so a
11//! request records a local CA's revocation in its ledger ([`Revoker::Ledger`],
12//! the worker signing the CRL after) or queues it for the worker
13//! ([`Revoker::Queued`], waiting on the job for the answer). The backend itself
14//! ([`Revoker::Backend`]) is reached only from [`SignerRevokeJob`], in the
15//! process that built it. [`Revoker::for_route`] is the choice every front end
16//! makes, from the profile's [`RevocationRoute`].
17//!
18//! Deliberately not an [`OrderService`](super::OrderService) method: that
19//! bundle carries a mounted [`Profile`](crate::profile::Profile), and the host
20//! CLI revokes with no profile mounted. What revocation needs is narrower —
21//! whatever withdraws trust, and whom to tell — so that is what
22//! [`Revocations`] holds.
23
24use std::sync::Arc;
25use std::time::Duration;
26
27use tracing::{error, info, warn};
28use uuid::Uuid;
29
30use acme_proxy_core::audit::Actor;
31use acme_proxy_core::audit::AuditEvent;
32use acme_proxy_core::audit::AuditRecord;
33use acme_proxy_core::audit::ClientContext;
34use acme_proxy_core::audit::RequestContext;
35use acme_proxy_core::error::Problem;
36use acme_proxy_jobs::auditor::Auditor;
37use acme_proxy_jobs::jobs::JobHandler;
38use acme_proxy_jobs::jobs::JobOutcome;
39use acme_proxy_jobs::jobs::JobQueue;
40use acme_proxy_jobs::jobs::JobSpec;
41use acme_proxy_jobs::notify::CertificateRevokedData;
42use acme_proxy_jobs::notify::NotifyDispatcher;
43use acme_proxy_jobs::notify::NotifyEvent;
44use acme_proxy_signer::RevocationRoute;
45use acme_proxy_signer::SignerBackend;
46use acme_proxy_signer::SignerError;
47use acme_proxy_store::account::Account;
48use acme_proxy_store::db::Database;
49use acme_proxy_store::job::Job;
50use acme_proxy_store::order::Order;
51
52/// What withdraws trust in a certificate.
53pub enum Revoker<'a> {
54    /// The backend that issued it, called inline — only ever from
55    /// [`SignerRevokeJob`], in the process that holds the backend.
56    Backend(&'a dyn SignerBackend),
57    /// A local CA's revocation state, written directly — no key, no signer.
58    ///
59    /// The `revocations` row and the order's stamp land in **one**
60    /// transaction, and `local_ca_crl_regenerate` asks whichever process holds
61    /// the key to sign the CRL. What the host CLI uses: a revocation is
62    /// recorded, and visible on the order, the moment the command returns,
63    /// where the CRL follows once a server's job runner gets to it.
64    Ledger {
65        /// The CA's issuer id ([`acme_proxy_signer::local_ca::issuer_id_of`]).
66        issuer: &'a str,
67        jobs: &'a JobQueue,
68    },
69    /// A backend only the `worker` role holds — an upstream CA or an operator
70    /// script: the revocation is a `signer_revoke` job, and this waits up to
71    /// `wait` for its answer. The job writes the success row and the
72    /// notification; a caller that stops waiting gets [`RevokeError::Pending`],
73    /// and the revocation carries on without it.
74    Queued { jobs: &'a JobQueue, wait: Duration },
75}
76
77impl<'a> Revoker<'a> {
78    /// What a front end with no backend uses for a profile whose read side
79    /// answered `route`: a local CA's ledger, or the queue.
80    #[must_use]
81    pub fn for_route(route: &'a RevocationRoute, jobs: &'a JobQueue, wait: Duration) -> Self {
82        match route {
83            RevocationRoute::Ledger { issuer } => Revoker::Ledger { issuer, jobs },
84            RevocationRoute::Delegated => Revoker::Queued { jobs, wait },
85        }
86    }
87}
88
89/// Where the address and reverse name an audit row stores come from.
90pub enum Client<'a> {
91    /// A request still to be resolved — resolved only once the operation is
92    /// sure to write a row, since a PTR lookup for a request about to be
93    /// turned away as unparsable buys nothing.
94    Request(&'a RequestContext),
95    /// Already resolved by the caller, or deliberately empty: the host CLI has
96    /// no request, and its rows say so.
97    Resolved(ClientContext),
98}
99
100impl Client<'_> {
101    async fn resolve(self, audit: &Auditor) -> ClientContext {
102        match self {
103            Client::Request(request) => audit.client(request).await,
104            Client::Resolved(client) => client,
105        }
106    }
107}
108
109/// Why a revocation did not happen.
110#[derive(Debug, thiserror::Error)]
111pub enum RevokeError {
112    /// A refusal only the ACME path makes — an unknown certificate, a request
113    /// signed by a key that may not revoke it — already audited and logged.
114    #[error("{}", .0.detail())]
115    Refused(Problem),
116    /// No order has that id.
117    #[error("no such order")]
118    NotFound,
119    /// The order never reached issuance.
120    #[error("order has no issued certificate")]
121    NotIssued,
122    #[error("certificate already revoked")]
123    AlreadyRevoked,
124    #[error("unsupported revocation reason code {0}")]
125    BadReason(u32),
126    /// The backend refused or failed. Nothing was recorded on the order, so a
127    /// retry is expected.
128    #[error("signer error: {}", signer_detail(.0))]
129    Signer(SignerError),
130    /// The trust was withdrawn but the order row could not say so.
131    #[error("database error: {0}")]
132    Database(sqlx::Error),
133    /// Queued for the worker, and not answered within the wait. The revocation
134    /// carries on: asking again waits on the same job.
135    #[error("the revocation is queued as job {job} and has not completed yet")]
136    Pending { job: Uuid },
137    /// The queued revocation was retired without revoking — its backend kept
138    /// failing, or an operator cancelled it.
139    #[error("the revocation failed (job {job}): {reason}")]
140    Abandoned { job: Uuid, reason: String },
141    /// The stored certificate cannot be read back.
142    #[error("internal error: {0}")]
143    Internal(String),
144}
145
146/// How a [`SignerError`] reads inside a [`RevokeError`].
147///
148/// `BadCsr` is not a thing `revoke` can legitimately answer — the hook takes a
149/// certificate, not a CSR — so it is reported as the contract violation it is
150/// rather than passed through as if it meant something here.
151pub fn signer_detail(error: &SignerError) -> String {
152    match error {
153        SignerError::Internal(detail) => detail.clone(),
154        SignerError::BadCsr => "unexpected badCsr from revoke".to_string(),
155    }
156}
157
158impl From<sqlx::Error> for RevokeError {
159    fn from(error: sqlx::Error) -> Self {
160        Self::Database(error)
161    }
162}
163
164/// The answer an ACME client reads for each refusal.
165impl From<RevokeError> for Problem {
166    fn from(error: RevokeError) -> Self {
167        match error {
168            RevokeError::Refused(problem) => problem,
169            RevokeError::NotFound | RevokeError::NotIssued => {
170                Problem::malformed("Unknown certificate")
171            }
172            RevokeError::AlreadyRevoked => Problem::already_revoked("Certificate already revoked"),
173            RevokeError::BadReason(reason) => Problem::bad_revocation_reason(format!(
174                "Unsupported revocation reason code {reason}"
175            )),
176            RevokeError::Pending { .. } => {
177                Problem::service_unavailable("The revocation is queued; retry to confirm it")
178            }
179            RevokeError::Signer(_)
180            | RevokeError::Database(_)
181            | RevokeError::Internal(_)
182            | RevokeError::Abandoned { .. } => Problem::server_internal("Revocation failed"),
183        }
184    }
185}
186
187/// Everything a revocation reaches.
188pub struct Revocations<'a> {
189    pub database: &'a Arc<Database>,
190    pub audit: &'a Auditor,
191    /// The certificate's profile's dispatcher, when this process has one — a
192    /// `certificate_revoked` notification is about the certificate, so it goes
193    /// out however the revocation was asked for.
194    pub notify: Option<&'a NotifyDispatcher>,
195    pub revoker: Revoker<'a>,
196}
197
198/// Which refusals become audit rows.
199#[derive(Clone, Copy, PartialEq, Eq)]
200enum Refusals {
201    /// A remote party turned away: every refusal is recorded, because a stream
202    /// of them is somebody probing.
203    Audited,
204    /// An operator told the state of things: nothing to record.
205    Silent,
206}
207
208impl Revocations<'_> {
209    /// `POST /revokeCert`: revokes the certificate `cert_der` issued at
210    /// `profile`, for a request signed by `pubkey`.
211    ///
212    /// Authorized by either the order's own account (RFC 8555 §7.6's `kid`
213    /// case, `account` being the one the JWS named) or the certificate's key
214    /// pair (the accountless `jwk` case). Every refusal past the point where a
215    /// serial is known is an audit row.
216    pub async fn revoke_certificate(
217        &self,
218        profile: &str,
219        cert_der: &[u8],
220        reason: Option<u32>,
221        pubkey: &[u8],
222        account: Option<Account>,
223        request: &RequestContext,
224    ) -> Result<Order, RevokeError> {
225        let database = self.database;
226        let (serial_hex, _) = acme_proxy_core::cert::cert_serial_and_spki(cert_der).map_err(|error| {
227            warn!(event = "certificate_revoke_parse_failed", outcome = "failure", error = %error);
228            RevokeError::Refused(Problem::malformed("certificate is unparsable"))
229        })?;
230
231        let order = Order::find_by_cert_serial(profile, &serial_hex, database)
232            .await
233            .map_err(|error| {
234                error!(event = "certificate_revoke_lookup_failed", outcome = "failure", cert_serial = %serial_hex, error = %error);
235                RevokeError::Refused(Problem::server_internal("Certificate lookup failed"))
236            })?
237            .filter(|order| {
238                order
239                    .certificate
240                    .as_deref()
241                    .and_then(|chain| acme_proxy_core::cert::leaf_der_from_chain(chain).ok())
242                    .is_some_and(|leaf| leaf == cert_der)
243            })
244            .ok_or(());
245
246        // Who is asking, as far as the JWS could tell. An embedded `jwk` names no
247        // account (RFC 8555 §7.6's accountless case), and that is recorded as an
248        // `acme` actor with no id rather than guessed at — the `cert_serial` on the
249        // row already says which key it must have been. Resolved here so the
250        // refusal rows below can name a signer this server has not authorized.
251        let actor = match &account {
252            Some(cached) => Actor::acme(cached.id.to_string()),
253            None => Actor::acme_certificate_key(),
254        };
255        // One reverse lookup for whichever arm answers.
256        let client = Client::Request(request).resolve(self.audit).await;
257        let revoke_failed = |reason: &'static str, detail: &str| {
258            refusal(
259                profile,
260                actor.clone(),
261                &serial_hex,
262                client.clone(),
263                reason,
264                detail,
265            )
266        };
267
268        let order = match order {
269            Ok(order) => order,
270            Err(()) => {
271                // Either no order carries this serial, or one does and its stored
272                // leaf is not byte-for-byte what was submitted. The two are not
273                // told apart on purpose: distinguishing them would confirm a serial
274                // exists to a caller who has not proven anything yet. The audit row
275                // does not distinguish them either, for the same reason — and it
276                // exists because a stream of these is somebody enumerating.
277                warn!(event = "certificate_revoke_unknown_certificate", outcome = "failure", cert_serial = %serial_hex);
278                self.audit
279                    .record(revoke_failed(
280                        "malformed",
281                        "no certificate issued here matches",
282                    ))
283                    .await;
284                return Err(RevokeError::Refused(Problem::malformed(
285                    "Unknown certificate",
286                )));
287            }
288        };
289
290        // Deliberately not gated on account status. RFC 8555 §7.6 gives two ways
291        // to authorize a revocation and only one of them involves an account at
292        // all — the certificate's own key pair is the accountless case.
293        //
294        // §7.3.6 says a server SHOULD NOT allow further requests by a deactivated
295        // account key, and this path knowingly does. Revocation only ever removes
296        // trust; refusing it would mean an operator who deactivated an account can
297        // no longer withdraw the certificates it holds, which is the worse failure
298        // in both directions. Pinned by
299        // `tests/revoke_cert.rs::deactivated_account_can_still_revoke_its_own_certificate`.
300        //
301        // **Never compare a key re-derived from the submitted DER**: the DER match
302        // above already proves it equals the stored leaf, so re-deriving would let
303        // anyone who merely observed the certificate revoke it with an unrelated
304        // key. The stored `cert_pubkey` is what is compared.
305        let cert_key_matches = order.cert_pubkey.as_deref() == Some(pubkey);
306        let authorized = if cert_key_matches {
307            true
308        } else {
309            match account {
310                Some(cached) => cached.id == order.account_id,
311                None => Account::find_by_pubkey(profile, pubkey, database)
312                    .await
313                    .map_err(|error| {
314                        error!(event = "certificate_revoke_account_lookup_failed", outcome = "failure", error = %error);
315                        RevokeError::Refused(Problem::server_internal("Account lookup failed"))
316                    })?
317                    .is_some_and(|found| found.id == order.account_id),
318            }
319        };
320        if !authorized {
321            warn!(event = "certificate_revoke_unauthorized", outcome = "failure", order_id = %order.id, cert_serial = %serial_hex);
322            // The one refusal here that is somebody else's certificate being
323            // attacked rather than a client's own mistake, and the reason failures
324            // are audited at all.
325            self.audit
326                .record(
327                    revoke_failed(
328                        "unauthorized",
329                        "signed by neither the order's account nor the certificate's own key",
330                    )
331                    .with_order(order.id, order.account_id, &order.identifiers),
332                )
333                .await;
334            return Err(RevokeError::Refused(Problem::unauthorized(
335                "Neither the order's account nor the certificate's own key signed this request",
336            )));
337        }
338
339        self.revoke(
340            order,
341            cert_der,
342            serial_hex,
343            reason,
344            actor,
345            client,
346            Refusals::Audited,
347        )
348        .await
349    }
350
351    /// An operator's revocation of order `id`'s certificate.
352    ///
353    /// `actor`/`client` name the operator and where they asked from — **not**
354    /// the certificate's owner, since an administrative revocation attributed to
355    /// the client would say the opposite of what happened. The refusals
356    /// (`NotFound`, `NotIssued`, `AlreadyRevoked`, a bad reason) write no row:
357    /// the operator is being told the state of things, unlike `revokeCert`'s
358    /// refusals, which are a remote party being turned away.
359    pub async fn revoke_order(
360        &self,
361        id: &str,
362        reason: Option<u32>,
363        actor: Actor,
364        client: ClientContext,
365    ) -> Result<Order, RevokeError> {
366        let Some(order) = Order::find_by_id(id, self.database).await? else {
367            return Err(RevokeError::NotFound);
368        };
369        let Some(chain) = order.certificate.as_deref() else {
370            return Err(RevokeError::NotIssued);
371        };
372        let cert_der = acme_proxy_core::cert::leaf_der_from_chain(chain).map_err(|error| {
373            RevokeError::Internal(format!("stored certificate chain is unparsable: {error}"))
374        })?;
375        // The stored serial where there is one, since that is the column the
376        // trail is searched by; re-read from the leaf otherwise.
377        let serial = match order.cert_serial.clone() {
378            Some(serial) => serial,
379            None => acme_proxy_core::cert::cert_serial_and_spki(&cert_der)
380                .map(|(serial, _)| serial)
381                .map_err(|error| {
382                    RevokeError::Internal(format!("stored certificate is unparsable: {error}"))
383                })?,
384        };
385        self.revoke(
386            order,
387            &cert_der,
388            serial,
389            reason,
390            actor,
391            client,
392            Refusals::Silent,
393        )
394        .await
395    }
396
397    /// The tail both doors share: already revoked → bad reason → the signer →
398    /// the order row → the trail → the notification.
399    #[allow(clippy::too_many_arguments)]
400    async fn revoke(
401        &self,
402        mut order: Order,
403        cert_der: &[u8],
404        serial_hex: String,
405        reason: Option<u32>,
406        actor: Actor,
407        client: ClientContext,
408        refusals: Refusals,
409    ) -> Result<Order, RevokeError> {
410        let refused = |order: &Order, reason: &'static str, detail: &str| {
411            refusal(
412                &order.profile,
413                actor.clone(),
414                &serial_hex,
415                client.clone(),
416                reason,
417                detail,
418            )
419            .with_order(order.id, order.account_id, &order.identifiers)
420        };
421        let audited = refusals == Refusals::Audited;
422
423        // *After* authorization, so an unauthorized caller cannot probe
424        // revocation state.
425        if order.revoked_at.is_some() {
426            if audited {
427                warn!(event = "certificate_revoke_already_revoked", outcome = "failure", order_id = %order.id, cert_serial = %serial_hex);
428                self.audit
429                    .record(refused(&order, "alreadyRevoked", "already revoked"))
430                    .await;
431            }
432            return Err(RevokeError::AlreadyRevoked);
433        }
434
435        if let Some(code) = reason
436            && !acme_proxy_core::cert::is_valid_revocation_reason(code)
437        {
438            if audited {
439                warn!(
440                    event = "certificate_revoke_bad_reason",
441                    outcome = "failure",
442                    reason = code
443                );
444                self.audit
445                    .record(refused(
446                        &order,
447                        "badRevocationReason",
448                        &format!("reason code {code}"),
449                    ))
450                    .await;
451            }
452            return Err(RevokeError::BadReason(code));
453        }
454
455        match self.revoker {
456            // Everything past this point is the job's: its tail writes the
457            // `certificate_revoked` row and the notification, from the process
458            // that holds the backend.
459            Revoker::Queued { jobs, wait } => {
460                return self
461                    .revoke_through_the_queue(&order, reason, &actor, &client, jobs, wait)
462                    .await;
463            }
464            // The signer first, then the order: the CA-side action is
465            // authoritative, so a failure there must leave the order un-revoked
466            // for a retry — and is audited as the attempt it was, whoever asked.
467            Revoker::Backend(signer) => {
468                if let Err(error) = signer.revoke(cert_der, reason).await {
469                    error!(event = "certificate_revoke_signer_failed", outcome = "failure", order_id = %order.id, cert_serial = %serial_hex, error = %error);
470                    self.audit
471                        .record(refused(&order, "serverInternal", &error.to_string()))
472                        .await;
473                    return Err(RevokeError::Signer(error));
474                }
475                // A `false` here — another writer stamped the order between the
476                // check above and this write — is the same answer that check
477                // gives, and the signer has withdrawn trust either way.
478                match order.revoke(reason.map(i64::from), self.database).await {
479                    Ok(false) => {
480                        if audited {
481                            self.audit
482                                .record(refused(&order, "alreadyRevoked", "already revoked"))
483                                .await;
484                        }
485                        return Err(RevokeError::AlreadyRevoked);
486                    }
487                    Ok(true) => {}
488                    Err(error) => {
489                        error!(event = "certificate_revoke_persist_failed", outcome = "failure", order_id = %order.id, cert_serial = %serial_hex, error = %error);
490                        // The signer already withdrew trust, so the CA-side
491                        // action stands; what failed is this server's record of
492                        // it. Audited as a failure because that is what a later
493                        // reader needs to know — the order still reads
494                        // un-revoked and a retry is expected.
495                        self.audit
496                            .record(refused(&order, "serverInternal", &error.to_string()))
497                            .await;
498                        return Err(RevokeError::Database(error));
499                    }
500                }
501            }
502            // The row and the order together, then the CRL asked for.
503            Revoker::Ledger { issuer, jobs } => {
504                if let Err(error) = self
505                    .record_in_ledger(&mut order, issuer, cert_der, &serial_hex, reason)
506                    .await
507                {
508                    self.audit
509                        .record(refused(&order, "serverInternal", &error.to_string()))
510                        .await;
511                    return Err(error);
512                }
513                // The revocation stands whether or not this lands: the daily
514                // refresh signs any recorded revocation its CRL does not list.
515                jobs.enqueue_or_log(acme_proxy_signer::local_ca::sweep::regenerate_spec(issuer))
516                    .await;
517            }
518        }
519
520        info!(event = "certificate_revoked", outcome = "success", order_id = %order.id, cert_serial = %serial_hex);
521        let revoked = AuditRecord::new(AuditEvent::CertificateRevoked, &order.profile, actor)
522            .with_order(order.id, order.account_id, &order.identifiers)
523            .with_serial(&serial_hex)
524            .with_client(client.clone());
525        // The RFC 5280 reason code, decimal, and left **absent** when none was
526        // given — which RFC 8555 §7.6 allows and which is not the same as
527        // `unspecified` (0). A `with_reason("")` here would make the two
528        // indistinguishable in the column that exists to tell them apart.
529        self.audit
530            .record(match reason {
531                Some(code) => revoked.with_reason(code.to_string()),
532                None => revoked,
533            })
534            .await;
535        if let Some(dispatcher) = self.notify {
536            dispatcher
537                .dispatch(NotifyEvent::CertificateRevoked(CertificateRevokedData {
538                    profile: order.profile.clone(),
539                    order_id: order.id.to_string(),
540                    account_id: order.account_id.to_string(),
541                    cert_serial: serial_hex,
542                    reason,
543                    client_ip: client.ip,
544                }))
545                .await;
546        }
547        Ok(order)
548    }
549}
550
551impl Revocations<'_> {
552    /// The queued half of [`Revoker::Queued`]: one `signer_revoke` row for the
553    /// worker, then the wait for its answer.
554    ///
555    /// A row already live for this order is waited on rather than duplicated —
556    /// the identity index refuses a second one — so a client retrying after
557    /// [`RevokeError::Pending`] follows the revocation it already started.
558    async fn revoke_through_the_queue(
559        &self,
560        order: &Order,
561        reason: Option<u32>,
562        actor: &Actor,
563        client: &ClientContext,
564        jobs: &JobQueue,
565        wait: Duration,
566    ) -> Result<Order, RevokeError> {
567        let id = order.id.to_string();
568        jobs.enqueue(signer_revoke_spec(&id, reason, actor, client))
569            .await
570            .inspect_err(|error| {
571                error!(event = "certificate_revoke_queue_failed", outcome = "failure", order_id = %id, error = %error);
572            })?;
573        let job = acme_proxy_store::job::Job::find_latest_by_dedup(
574            SIGNER_REVOKE_KIND,
575            &id,
576            self.database,
577        )
578        .await?
579        .ok_or_else(|| {
580            RevokeError::Internal(format!("the revocation of order {id} was not queued"))
581        })?;
582        info!(event = "certificate_revoke_queued", outcome = "progress", order_id = %id, job_id = %job.id);
583
584        match await_job(self.database, job.id, wait).await? {
585            JobSettled::Done => Order::find_by_id(&id, self.database)
586                .await?
587                .ok_or(RevokeError::NotFound),
588            JobSettled::Failed(reason) => Err(RevokeError::Abandoned {
589                job: job.id,
590                reason,
591            }),
592            JobSettled::Cancelled => Err(RevokeError::Abandoned {
593                job: job.id,
594                reason: "cancelled by an operator".to_string(),
595            }),
596            JobSettled::Pending => Err(RevokeError::Pending { job: job.id }),
597        }
598    }
599
600    /// The ledger half of [`Revoker::Ledger`]: the `revocations` row and the
601    /// order's stamp in one transaction, `order` synced after the commit.
602    ///
603    /// Refused while the CA has no stored CRL. A CA meets the database through
604    /// its first process holding the key, which imports any pre-database
605    /// `ca.json` ledger in the same transaction as that first CRL — and a
606    /// revocation landing before that import would be written under a row the
607    /// import then believes it owns. Any server that has run with this CA has
608    /// stored one.
609    async fn record_in_ledger(
610        &self,
611        order: &mut Order,
612        issuer: &str,
613        cert_der: &[u8],
614        serial_hex: &str,
615        reason: Option<u32>,
616    ) -> Result<(), RevokeError> {
617        let revoked_at = acme_proxy_store::nonce::now_secs();
618        let row = acme_proxy_store::revocation::Revocation {
619            issuer: issuer.to_string(),
620            serial: serial_hex.to_string(),
621            revoked_at,
622            reason,
623            // Best-effort, as on the signer's own path: an expiry this server
624            // cannot read only means the entry is never pruned.
625            not_after: acme_proxy_core::cert::cert_validity(cert_der)
626                .ok()
627                .map(|(_, not_after)| not_after),
628        };
629        let written = async {
630            // Immediate: this reads the CA's stored CRL and then writes on the
631            // strength of it, while the worker — another process, in a split
632            // deployment — may be storing a CRL for the same issuer. Deferred,
633            // the upgrade would fail with `SQLITE_BUSY_SNAPSHOT` instead of
634            // waiting its turn.
635            let mut tx = self.database.write_transaction().await?;
636            if acme_proxy_store::crl::StoredCrl::find(issuer, tx.conn())
637                .await?
638                .is_none()
639            {
640                return Ok(Recorded::NoCrlYet);
641            }
642            // The order first: its guard is what decides whether this
643            // revocation is the one being recorded. Losing it means another
644            // writer — the CLI beside a running server, or a second request —
645            // got there between the check and here, and the ledger row it wrote
646            // is the one that stands, reason and all.
647            if !Order::set_revoked(order.id, reason.map(i64::from), revoked_at, tx.conn()).await? {
648                return Ok(Recorded::Already);
649            }
650            row.insert_if_absent(tx.conn()).await?;
651            tx.commit().await?;
652            Ok::<Recorded, sqlx::Error>(Recorded::Yes)
653        }
654        .await;
655        let recorded = written.map_err(|error| {
656            error!(event = "certificate_revoke_persist_failed", outcome = "failure", order_id = %order.id, cert_serial = %serial_hex, error = %error);
657            RevokeError::Database(error)
658        })?;
659        if recorded == Recorded::Already {
660            warn!(event = "certificate_revoke_already_revoked", outcome = "failure", order_id = %order.id, cert_serial = %serial_hex);
661            return Err(RevokeError::AlreadyRevoked);
662        }
663        if recorded == Recorded::NoCrlYet {
664            warn!(event = "certificate_revoke_ca_uninitialized", outcome = "failure", order_id = %order.id, issuer = %issuer);
665            return Err(RevokeError::Internal(format!(
666                "the CA {issuer} has no stored CRL yet — start `acme-proxy serve` with this \
667                 configuration once, so it can import its revocation ledger, then retry"
668            )));
669        }
670        order.revoked_at = Some(revoked_at);
671        order.revocation_reason = reason.map(i64::from);
672        Ok(())
673    }
674}
675
676/// What one pass of [`Revocations::record_in_ledger`]'s transaction did.
677#[derive(Debug, PartialEq, Eq)]
678enum Recorded {
679    Yes,
680    /// Another writer had already stamped the order.
681    Already,
682    /// This CA has never stored a CRL, so there is nothing to record against.
683    NoCrlYet,
684}
685
686/// One `certificate_revoke_failed` row.
687fn refusal(
688    profile: &str,
689    actor: Actor,
690    serial: &str,
691    client: ClientContext,
692    reason: &'static str,
693    detail: &str,
694) -> AuditRecord {
695    AuditRecord::new(AuditEvent::CertificateRevokeFailed, profile, actor)
696        .with_serial(serial)
697        .with_client(client)
698        .with_reason(reason)
699        .with_detail(detail)
700}
701
702/// The `jobs.kind` of a revocation a delegating backend must perform.
703pub const SIGNER_REVOKE_KIND: &str = "signer_revoke";
704
705/// The row asking a running server to revoke order `order_id`'s certificate
706/// at its backend, on behalf of `actor`.
707///
708/// Keyed on the order, so asking twice while the first is still queued is one
709/// revocation. The payload names who asked and from where, never the
710/// certificate: the row is read back when it runs, so a retry sees the order as
711/// it is then, and the `certificate_revoked` row the worker writes still names
712/// the client or operator whose request queued it.
713#[must_use]
714pub fn signer_revoke_spec(
715    order_id: &str,
716    reason: Option<u32>,
717    actor: &Actor,
718    client: &ClientContext,
719) -> JobSpec {
720    JobSpec::now(SIGNER_REVOKE_KIND, order_id).with_payload(serde_json::json!({
721        "order_id": order_id,
722        "reason": reason,
723        "actor_kind": actor.kind.as_str(),
724        "actor_id": actor.id,
725        "client": client.to_json(),
726    }))
727}
728
729/// How long a request waits for a revocation it queued: its own deadline,
730/// `server.request_timeout_ms`, less a second to answer in — so a slow worker
731/// is told as [`RevokeError::Pending`] rather than cut off as a timeout that
732/// says nothing about the job still running.
733#[must_use]
734pub fn request_wait(request_timeout_ms: u64) -> Duration {
735    Duration::from_millis(request_timeout_ms).saturating_sub(Duration::from_secs(1))
736}
737
738/// How a job a caller is waiting on ended, or that it has not yet.
739#[derive(Debug, Clone, PartialEq, Eq)]
740pub enum JobSettled {
741    Done,
742    /// Retired for good, with the reason its last attempt gave.
743    Failed(String),
744    Cancelled,
745    /// Still `ready` or `running` when the wait ran out.
746    Pending,
747}
748
749/// How often [`await_job`] reads the row. A worker in another process picks a
750/// row up within `jobs.poll_interval_ms`, so reading faster than this would
751/// only cost queries.
752const AWAIT_PACE: Duration = Duration::from_millis(100);
753
754/// Waits up to `wait` for job `id` to settle.
755///
756/// Shared by every front end that queues work and answers with its outcome —
757/// `order revoke` from the host, the panel's revoke button, and `revokeCert`
758/// for a backend only the worker holds. A job that vanished is `Failed`:
759/// nothing deletes a live row, so it went with its order.
760pub async fn await_job(
761    database: &Database,
762    id: Uuid,
763    wait: Duration,
764) -> Result<JobSettled, sqlx::Error> {
765    let deadline = tokio::time::Instant::now() + wait;
766    loop {
767        let Some(job) = acme_proxy_store::job::Job::find_by_id(id, database).await? else {
768            return Ok(JobSettled::Failed(format!("job {id} disappeared")));
769        };
770        match job.status.as_str() {
771            "done" => return Ok(JobSettled::Done),
772            "failed" => {
773                return Ok(JobSettled::Failed(
774                    job.last_error
775                        .unwrap_or_else(|| "no reason recorded".to_string()),
776                ));
777            }
778            "cancelled" => return Ok(JobSettled::Cancelled),
779            _ if tokio::time::Instant::now() >= deadline => return Ok(JobSettled::Pending),
780            _ => tokio::time::sleep(AWAIT_PACE).await,
781        }
782    }
783}
784
785/// Revokes, at the backend that issued it, a certificate somebody asked to
786/// revoke from a process holding no backend — `revokeCert` in the `acme` role,
787/// the panel in the `admin` role, the host CLI — for a `relay` or `custom`
788/// profile, whose revocation is a call to an upstream CA or a script.
789///
790/// **One handler over every profile**, the `RelayJob` shape: a row names its
791/// order, the order names its profile, and the profile names the backend.
792pub struct SignerRevokeJob {
793    database: Arc<Database>,
794    audit: Arc<Auditor>,
795    signers: Vec<(String, Arc<dyn SignerBackend>)>,
796    notifiers: acme_proxy_jobs::notify::Notifiers,
797}
798
799impl SignerRevokeJob {
800    /// `signers` is this generation's backend per profile name.
801    #[must_use]
802    pub fn new(
803        database: Arc<Database>,
804        audit: Arc<Auditor>,
805        signers: Vec<(String, Arc<dyn SignerBackend>)>,
806        notifiers: acme_proxy_jobs::notify::Notifiers,
807    ) -> Self {
808        Self {
809            database,
810            audit,
811            signers,
812            notifiers,
813        }
814    }
815}
816
817/// The actor a queued revocation names, read back out of its payload.
818fn payload_actor(payload: &serde_json::Value) -> Actor {
819    let id = payload["actor_id"].as_str().map(str::to_string);
820    let kind = match payload["actor_kind"].as_str() {
821        Some("admin") => acme_proxy_core::audit::ActorKind::Admin,
822        Some("acme") => acme_proxy_core::audit::ActorKind::Acme,
823        Some("system") => acme_proxy_core::audit::ActorKind::System,
824        _ => acme_proxy_core::audit::ActorKind::Cli,
825    };
826    Actor { kind, id }
827}
828
829#[async_trait::async_trait]
830impl JobHandler for SignerRevokeJob {
831    fn kind(&self) -> &'static str {
832        SIGNER_REVOKE_KIND
833    }
834
835    /// What is worth asking again (`Retry`): a backend that failed, a database
836    /// that did, a profile this process does not mount — another process, or
837    /// the next generation of this one, may. What is not (`Failed`): an order
838    /// that is gone, never issued, or a reason code no backend accepts. An
839    /// order already revoked is `Done`: the operator's intent holds.
840    async fn run(&self, job: &Job) -> JobOutcome {
841        let payload = &job.payload;
842        let Some(order_id) = payload["order_id"].as_str() else {
843            return JobOutcome::Failed("the payload names no order".to_string());
844        };
845        let reason = payload["reason"]
846            .as_u64()
847            .and_then(|code| u32::try_from(code).ok());
848
849        let order = match Order::find_by_id(order_id, &self.database).await {
850            Ok(Some(order)) => order,
851            Ok(None) => return JobOutcome::Failed("the order no longer exists".to_string()),
852            Err(error) => return JobOutcome::Retry(format!("reading the order failed: {error}")),
853        };
854        let Some(signer) = super::mounted(&self.signers, &order.profile) else {
855            return JobOutcome::Retry(format!(
856                "profile `{}` is not mounted by this process",
857                order.profile
858            ));
859        };
860        let dispatcher = self.notifiers.get(&order.profile);
861        let revocations = Revocations {
862            database: &self.database,
863            audit: &self.audit,
864            notify: dispatcher.as_deref(),
865            revoker: Revoker::Backend(signer.as_ref()),
866        };
867        match revocations
868            .revoke_order(
869                order_id,
870                reason,
871                payload_actor(payload),
872                ClientContext::from_json(&payload["client"]),
873            )
874            .await
875        {
876            Ok(_) | Err(RevokeError::AlreadyRevoked) => JobOutcome::Done,
877            Err(error @ (RevokeError::Signer(_) | RevokeError::Database(_))) => {
878                JobOutcome::Retry(error.to_string())
879            }
880            Err(error) => JobOutcome::Failed(error.to_string()),
881        }
882    }
883
884    async fn abandon(&self, job: &Job, reason: &str) {
885        error!(
886            event = "certificate_revoke_abandoned",
887            outcome = "failure",
888            order_id = %job.dedup_key,
889            reason = %reason,
890            "a queued revocation was given up; the certificate is still trusted"
891        );
892    }
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898    use acme_proxy_core::identifier::Identifier;
899    use acme_proxy_jobs::jobs::JobHandler;
900    use acme_proxy_signer::IssueOutcome;
901    use acme_proxy_signer::RequestedValidity;
902
903    /// A backend whose `revoke` always fails.
904    struct Failing;
905
906    #[async_trait::async_trait]
907    impl SignerBackend for Failing {
908        async fn issue(
909            &self,
910            _order_id: &str,
911            _csr_der: &[u8],
912            _identifiers: &[Identifier],
913            _validity: RequestedValidity,
914        ) -> Result<IssueOutcome, SignerError> {
915            Err(SignerError::Internal("not here".into()))
916        }
917        async fn revoke(&self, _cert_der: &[u8], _reason: Option<u32>) -> Result<(), SignerError> {
918            Err(SignerError::Internal("upstream unreachable".into()))
919        }
920    }
921
922    fn handler(database: &Arc<Database>) -> SignerRevokeJob {
923        SignerRevokeJob::new(
924            database.clone(),
925            Arc::new(Auditor::offline(database.clone())),
926            vec![("default".to_string(), Arc::new(Failing))],
927            std::collections::HashMap::new().into(),
928        )
929    }
930
931    async fn queued(database: &Arc<Database>, order_id: &str) -> Job {
932        let queue = acme_proxy_jobs::testutil::idle_job_queue(database.clone());
933        queue
934            .enqueue(signer_revoke_spec(
935                order_id,
936                None,
937                &Actor::cli(),
938                &ClientContext::default(),
939            ))
940            .await
941            .unwrap();
942        Job::find_live(SIGNER_REVOKE_KIND, order_id, database)
943            .await
944            .unwrap()
945            .unwrap()
946    }
947
948    /// A backend that failed is asked again; the order stays un-revoked.
949    #[tokio::test]
950    async fn a_failing_backend_is_retried() {
951        let database = Arc::new(Database::connect_in_memory().await.unwrap());
952        let account = acme_proxy_store::testutil::account_id(&database).await;
953        let order = acme_proxy_store::testutil::issued_order(
954            &database,
955            "default",
956            account,
957            &["example.com"],
958            30,
959        )
960        .await;
961        let job = queued(&database, &order.id.to_string()).await;
962
963        assert!(matches!(
964            handler(&database).run(&job).await,
965            JobOutcome::Retry(_)
966        ));
967        let stored = Order::find_by_id(&order.id.to_string(), &database)
968            .await
969            .unwrap()
970            .unwrap();
971        assert!(stored.revoked_at.is_none());
972    }
973
974    /// A backend whose `revoke` always succeeds.
975    struct Succeeding;
976
977    #[async_trait::async_trait]
978    impl SignerBackend for Succeeding {
979        async fn issue(
980            &self,
981            _order_id: &str,
982            _csr_der: &[u8],
983            _identifiers: &[Identifier],
984            _validity: RequestedValidity,
985        ) -> Result<IssueOutcome, SignerError> {
986            Err(SignerError::Internal("not here".into()))
987        }
988        async fn revoke(&self, _cert_der: &[u8], _reason: Option<u32>) -> Result<(), SignerError> {
989            Ok(())
990        }
991    }
992
993    /// A queue drained by a worker running [`SignerRevokeJob`] over `backend`,
994    /// with `max_attempts` of its own and no backoff. The shutdown sender comes
995    /// back so the runner lives exactly as long as the test holds it.
996    fn worker(
997        database: &Arc<Database>,
998        backend: Arc<dyn SignerBackend>,
999        max_attempts: u32,
1000    ) -> (JobQueue, tokio::sync::watch::Sender<bool>) {
1001        let config = acme_proxy_core::config::JobsConfig {
1002            poll_interval_ms: 10,
1003            max_attempts,
1004            retry_base_seconds: 0,
1005            retry_max_seconds: 0,
1006            ..acme_proxy_core::config::JobsConfig::default()
1007        };
1008        let queue = JobQueue::new(database.clone(), &config);
1009        let mut registry = acme_proxy_jobs::jobs::JobRegistry::new();
1010        registry
1011            .register(Arc::new(SignerRevokeJob::new(
1012                database.clone(),
1013                Arc::new(Auditor::offline(database.clone())),
1014                vec![("default".to_string(), backend)],
1015                std::collections::HashMap::new().into(),
1016            )))
1017            .unwrap();
1018        let (shutdown, receiver) = tokio::sync::watch::channel(false);
1019        acme_proxy_jobs::jobs::spawn_runner(queue.clone(), Arc::new(registry), &config, receiver);
1020        (queue, shutdown)
1021    }
1022
1023    async fn issued(database: &Arc<Database>) -> Order {
1024        let account = acme_proxy_store::testutil::account_id(database).await;
1025        acme_proxy_store::testutil::issued_order(database, "default", account, &["example.com"], 30)
1026            .await
1027    }
1028
1029    fn operator_client() -> ClientContext {
1030        ClientContext {
1031            ip: Some("198.51.100.4".to_string()),
1032            ..ClientContext::default()
1033        }
1034    }
1035
1036    /// A request in a process holding no backend queues the revocation and
1037    /// answers once the worker has performed it — and the one
1038    /// `certificate_revoked` row, written by the worker, still names who asked
1039    /// and from where.
1040    #[tokio::test]
1041    async fn a_queued_revocation_answers_once_the_worker_has_revoked() {
1042        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1043        let order = issued(&database).await;
1044        let (jobs, _worker) = worker(&database, Arc::new(Succeeding), 5);
1045        let audit = Auditor::offline(database.clone());
1046        let revocations = Revocations {
1047            database: &database,
1048            audit: &audit,
1049            notify: None,
1050            revoker: Revoker::Queued {
1051                jobs: &jobs,
1052                wait: Duration::from_secs(10),
1053            },
1054        };
1055
1056        let revoked = revocations
1057            .revoke_order(
1058                &order.id.to_string(),
1059                Some(1),
1060                Actor::admin("root"),
1061                operator_client(),
1062            )
1063            .await
1064            .unwrap();
1065        assert!(revoked.revoked_at.is_some());
1066        assert_eq!(revoked.revocation_reason, Some(1));
1067
1068        let query = acme_proxy_store::audit::AuditQuery {
1069            limit: 50,
1070            ..acme_proxy_store::audit::AuditQuery::default()
1071        };
1072        let (rows, _) = acme_proxy_store::audit::AuditEntry::search(&query, &database)
1073            .await
1074            .unwrap();
1075        assert_eq!(rows.len(), 1, "{rows:?}");
1076        assert_eq!(rows[0].event, "certificate_revoked");
1077        assert_eq!(rows[0].actor_kind, "admin");
1078        assert_eq!(rows[0].client_ip.as_deref(), Some("198.51.100.4"));
1079    }
1080
1081    /// Two revocations of one certificate at once — a client and an operator,
1082    /// or the CLI beside a running server. One of them records the withdrawal
1083    /// of trust; the other is told it was already revoked, and writes no second
1084    /// `certificate_revoked` row with a different reason and time.
1085    #[tokio::test]
1086    async fn concurrent_revocations_of_one_certificate_record_one() {
1087        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1088        let order = issued(&database).await;
1089        let audit = Auditor::offline(database.clone());
1090        let backend = Succeeding;
1091        let revocations = Revocations {
1092            database: &database,
1093            audit: &audit,
1094            notify: None,
1095            revoker: Revoker::Backend(&backend),
1096        };
1097        let order_id = order.id.to_string();
1098        let revoke = |reason: u32| {
1099            revocations.revoke_order(
1100                &order_id,
1101                Some(reason),
1102                Actor::admin("root"),
1103                operator_client(),
1104            )
1105        };
1106
1107        let (first, second) = tokio::join!(revoke(1), revoke(4));
1108        let outcomes = [first, second];
1109        assert_eq!(
1110            outcomes.iter().filter(|outcome| outcome.is_ok()).count(),
1111            1,
1112            "exactly one revocation records the withdrawal: {outcomes:?}"
1113        );
1114        assert!(
1115            outcomes
1116                .iter()
1117                .any(|outcome| matches!(outcome, Err(RevokeError::AlreadyRevoked)))
1118        );
1119
1120        let query = acme_proxy_store::audit::AuditQuery {
1121            limit: 50,
1122            ..acme_proxy_store::audit::AuditQuery::default()
1123        };
1124        let (rows, _) = acme_proxy_store::audit::AuditEntry::search(&query, &database)
1125            .await
1126            .unwrap();
1127        assert_eq!(
1128            rows.iter()
1129                .filter(|row| row.event == "certificate_revoked")
1130                .count(),
1131            1,
1132            "{rows:?}"
1133        );
1134
1135        // And the stamp on the order is the one that won, not the last writer's.
1136        let winner = outcomes
1137            .iter()
1138            .find_map(|outcome| outcome.as_ref().ok())
1139            .unwrap();
1140        let stored = Order::find_by_id(&order.id.to_string(), &database)
1141            .await
1142            .unwrap()
1143            .unwrap();
1144        assert_eq!(stored.revocation_reason, winner.revocation_reason);
1145        assert_eq!(stored.revoked_at, winner.revoked_at);
1146    }
1147
1148    /// Nothing drains the queue within the wait: the caller is told the
1149    /// revocation is pending, not that it failed — and asking again waits on
1150    /// the same row rather than queueing a second.
1151    #[tokio::test]
1152    async fn an_unanswered_revocation_is_pending_and_asking_again_follows_it() {
1153        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1154        let order = issued(&database).await;
1155        let jobs = acme_proxy_jobs::testutil::idle_job_queue(database.clone());
1156        let audit = Auditor::offline(database.clone());
1157        let revocations = Revocations {
1158            database: &database,
1159            audit: &audit,
1160            notify: None,
1161            revoker: Revoker::Queued {
1162                jobs: &jobs,
1163                wait: Duration::ZERO,
1164            },
1165        };
1166
1167        let mut seen = Vec::new();
1168        for _ in 0..2 {
1169            match revocations
1170                .revoke_order(
1171                    &order.id.to_string(),
1172                    None,
1173                    Actor::cli(),
1174                    ClientContext::default(),
1175                )
1176                .await
1177            {
1178                Err(RevokeError::Pending { job }) => seen.push(job),
1179                other => panic!("expected a pending revocation, got {other:?}"),
1180            }
1181        }
1182        assert_eq!(seen[0], seen[1], "the second ask follows the first job");
1183        assert_eq!(
1184            Job::count_live(SIGNER_REVOKE_KIND, &database)
1185                .await
1186                .unwrap(),
1187            1
1188        );
1189        let problem = Problem::from(RevokeError::Pending { job: seen[0] }).to_value();
1190        assert_eq!(problem["status"], 503);
1191        assert_eq!(problem["type"], "urn:ietf:params:acme:error:serverInternal");
1192    }
1193
1194    /// A revocation the worker gave up on is reported as the failure it was,
1195    /// with the job's own reason, and the order stays un-revoked.
1196    #[tokio::test]
1197    async fn a_revocation_the_worker_gave_up_on_is_abandoned() {
1198        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1199        let order = issued(&database).await;
1200        let (jobs, _worker) = worker(&database, Arc::new(Failing), 1);
1201        let audit = Auditor::offline(database.clone());
1202        let revocations = Revocations {
1203            database: &database,
1204            audit: &audit,
1205            notify: None,
1206            revoker: Revoker::Queued {
1207                jobs: &jobs,
1208                wait: Duration::from_secs(10),
1209            },
1210        };
1211
1212        let error = revocations
1213            .revoke_order(
1214                &order.id.to_string(),
1215                None,
1216                Actor::cli(),
1217                ClientContext::default(),
1218            )
1219            .await
1220            .unwrap_err();
1221        let RevokeError::Abandoned { reason, .. } = &error else {
1222            panic!("expected an abandoned revocation, got {error:?}")
1223        };
1224        assert!(reason.contains("upstream unreachable"), "{reason}");
1225        assert_eq!(
1226            Problem::from(error).to_value()["detail"],
1227            "Revocation failed"
1228        );
1229        let stored = Order::find_by_id(&order.id.to_string(), &database)
1230            .await
1231            .unwrap()
1232            .unwrap();
1233        assert!(stored.revoked_at.is_none());
1234    }
1235
1236    /// An operator cancelling the queued row is an answer too, and a row that
1237    /// vanished is a failure — neither leaves the caller waiting out its time.
1238    #[tokio::test]
1239    async fn a_cancelled_or_vanished_job_settles_the_wait() {
1240        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1241        let order = issued(&database).await;
1242        let job = queued(&database, &order.id.to_string()).await;
1243        Job::cancel_row(
1244            job.id,
1245            acme_proxy_store::status::JobStatus::Ready,
1246            &database,
1247        )
1248        .await
1249        .unwrap()
1250        .unwrap();
1251        assert_eq!(
1252            await_job(&database, job.id, Duration::from_secs(10))
1253                .await
1254                .unwrap(),
1255            JobSettled::Cancelled
1256        );
1257        let JobSettled::Failed(reason) = await_job(
1258            &database,
1259            acme_proxy_store::id::mint(),
1260            Duration::from_secs(10),
1261        )
1262        .await
1263        .unwrap() else {
1264            panic!("a job that is not there has failed")
1265        };
1266        assert!(reason.contains("disappeared"), "{reason}");
1267    }
1268
1269    /// Each route picks its revoker, and a request waits a second less than its
1270    /// own deadline so it can still answer.
1271    #[tokio::test]
1272    async fn the_route_picks_the_revoker_and_the_wait_fits_the_deadline() {
1273        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1274        let jobs = acme_proxy_jobs::testutil::idle_job_queue(database);
1275        let ledger = RevocationRoute::Ledger {
1276            issuer: "ab".to_string(),
1277        };
1278        assert!(matches!(
1279            Revoker::for_route(&ledger, &jobs, Duration::ZERO),
1280            Revoker::Ledger { issuer: "ab", .. }
1281        ));
1282        assert!(matches!(
1283            Revoker::for_route(&RevocationRoute::Delegated, &jobs, Duration::from_secs(3)),
1284            Revoker::Queued { wait, .. } if wait == Duration::from_secs(3)
1285        ));
1286        assert_eq!(request_wait(60_000), Duration::from_secs(59));
1287        assert_eq!(request_wait(500), Duration::ZERO);
1288    }
1289
1290    /// An order that is gone will not come back: retrying is pointless.
1291    #[tokio::test]
1292    async fn a_vanished_order_fails_for_good() {
1293        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1294        let job = queued(&database, &acme_proxy_store::id::mint().to_string()).await;
1295        assert!(matches!(
1296            handler(&database).run(&job).await,
1297            JobOutcome::Failed(_)
1298        ));
1299    }
1300}