Skip to main content

acme_proxy_store/
expiring.rs

1//! The expiry list: one query ([`Order::find_expiring`]), one annotator
2//! ([`annotate_expiring`]) and three consumers — the `[notify.expiry]` digest,
3//! the panel (`GET /api/expiring` and `/ui/expiring`) and
4//! `order list --expiring-in`. The annotation used to live inside the digest's
5//! job type, where the panel could not reach it — two answers to "has this been
6//! replaced?" was exactly one too many.
7//!
8//! Here, over the storage it reads, rather than in the operation layer: the
9//! digest is below that layer and the panel and the terminal above it, so this
10//! is the one place all three can reach.
11
12use std::collections::{BTreeSet, HashMap};
13use std::sync::Arc;
14
15use uuid::Uuid;
16
17use crate::db::Database;
18use crate::nonce::now_secs;
19use crate::order::{Order, UNPARSABLE_NOT_AFTER};
20use acme_proxy_core::config::Config;
21
22/// The window the panel opens on when the caller names no `days`.
23///
24/// Only reached when `[notify.expiry]` is off (`lead_days = 0`): a deployment
25/// that has chosen a lead time gets that one, since the operator reading the
26/// page is the operator who set it.
27const DEFAULT_LEAD_DAYS: u64 = 30;
28
29/// The certificate that has taken an expiring one's place, and how that was
30/// established.
31///
32/// `via` is carried rather than inferred because the two signals do not mean
33/// the same thing to an operator: `replaces` is the client *saying* it renewed
34/// (RFC 9773 §5, exact but only from clients that send one), where
35/// `identifiers` is this server noticing a later certificate covering the same
36/// names — a good inference, and still an inference.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38pub struct SupersededBy {
39    pub order_id: String,
40    pub cert_serial: String,
41    pub not_after: i64,
42    /// `"replaces"` or `"identifiers"`.
43    pub via: String,
44}
45
46/// One expiring certificate, annotated: the order row, how long it has left,
47/// and whether anything has replaced it.
48#[derive(Debug)]
49pub struct ExpiringEntry {
50    pub order: Order,
51    pub days_remaining: i64,
52    pub superseded_by: Option<SupersededBy>,
53}
54
55/// The window [`list_expiring`] answers.
56pub struct ExpiringQuery {
57    /// `None` is every endpoint, the panel's default. The digest names one.
58    pub profile: Option<String>,
59    /// The `cert_not_after` at or below which a row is expiring — build it with
60    /// [`expiring_horizon`], or [`horizon_from`] where the caller has its own
61    /// `now`, rather than computing it a second time.
62    pub before: i64,
63    /// Whether rows something has already replaced stay in the answer. They do
64    /// by default, and the digest never turns them off: see [`list_expiring`]
65    /// for what this cannot do to `total`.
66    pub include_superseded: bool,
67    pub limit: i64,
68    pub offset: i64,
69}
70
71/// The `cert_not_after` at or below which a certificate counts as expiring.
72///
73/// [`audit_cutoff`](crate::audit::audit_cutoff)'s twin, and for its
74/// reason: one function so the digest, the panel and
75/// `order list --expiring-in` cannot come to disagree by a rounding rule.
76#[must_use]
77pub fn expiring_horizon(days: u64) -> i64 {
78    horizon_from(now_secs(), days.saturating_mul(24 * 60 * 60))
79}
80
81/// [`expiring_horizon`] for a caller that already has its own `now` and a lead
82/// time in seconds — the expiry digest, whose `notify.expiry.lead` is a
83/// duration and whose `now` is the instant the whole digest is built against.
84#[must_use]
85pub fn horizon_from(now: i64, lead_seconds: u64) -> i64 {
86    now.saturating_add(i64::try_from(lead_seconds).unwrap_or(i64::MAX))
87}
88
89/// Whole days from `now` to `not_after`, floored, and never negative — a
90/// certificate that lapsed between the query and here is "0 days", not "-1".
91///
92/// Hoisted out of the digest so the mail, the page and the terminal round the
93/// same way; computing it in a Jinja template from two epoch seconds is
94/// arithmetic no template should carry.
95#[must_use]
96pub fn days_remaining(not_after: i64, now: i64) -> i64 {
97    not_after.saturating_sub(now).max(0) / (24 * 60 * 60)
98}
99
100/// The lead time a surface should default to: the configured one, or
101/// [`DEFAULT_LEAD_DAYS`] when the digest is switched off.
102#[must_use]
103pub fn default_lead_days(config: &Config) -> u64 {
104    match config.notify.expiry.lead_days {
105        0 => DEFAULT_LEAD_DAYS,
106        days => days,
107    }
108}
109
110/// Whether something has taken `order`'s certificate's place, and how that was
111/// established.
112///
113/// Two signals, tried strongest first, and both deliberately narrow. The
114/// annotation errs towards `None` throughout: a wrong "already renewed" is an
115/// operator ignoring a certificate that really is about to lapse, where a
116/// missing one is only noise. `notify::expiry`'s module docs carry that
117/// argument in full.
118///
119/// `candidates` is the account's own orders, passed in rather than fetched, so
120/// [`annotate_expiring`] can read them once for a whole listing. A caller with
121/// one order and no cache hands it [`Order::find_by_account`]'s result.
122pub async fn superseded_by(
123    order: &Order,
124    candidates: &[Order],
125    database: &Database,
126) -> Result<Option<SupersededBy>, sqlx::Error> {
127    // 1. The client said so (RFC 9773 §5). Exact when it is there at all,
128    //    but only clients that send `replaces` produce it.
129    //
130    //    `find_by_replaces` excludes only `invalid`, because its own
131    //    question is "has this predecessor been claimed" — a *pending*
132    //    claim still holds the claim. That is the wrong answer here: an
133    //    order that has not issued anything has replaced nothing, and
134    //    reporting its predecessor as renewed would silence the one
135    //    certificate still doing the work.
136    if let Some(chain) = order.certificate.as_deref()
137        && let Some(cert_id) = ari_cert_id(chain)
138        && let Some(successor) = Order::find_by_replaces(&order.profile, &cert_id, database).await?
139        && successor.certificate.is_some()
140        && successor.revoked_at.is_none()
141    {
142        return Ok(Some(SupersededBy {
143            order_id: successor.id.to_string(),
144            cert_serial: successor.cert_serial.unwrap_or_default(),
145            not_after: successor.cert_not_after.unwrap_or_default(),
146            via: "replaces".to_string(),
147        }));
148    }
149
150    // 2. This server noticed a later certificate covering the same names.
151    //    Scoped to the *same account*, and requiring a superset rather than
152    //    an intersection: a certificate held by somebody else is not this
153    //    subscriber's renewal, and one covering only some of these names
154    //    leaves the rest uncovered.
155    let names: BTreeSet<&str> = order
156        .identifiers
157        .iter()
158        .map(|identifier| identifier.value.as_str())
159        .collect();
160    let expires = order.cert_not_after.unwrap_or_default();
161    for candidate in candidates {
162        if candidate.id == order.id
163            || candidate.certificate.is_none()
164            || candidate.revoked_at.is_some()
165            || candidate.cert_not_after.unwrap_or(UNPARSABLE_NOT_AFTER) <= expires
166        {
167            continue;
168        }
169        let covered: BTreeSet<&str> = candidate
170            .identifiers
171            .iter()
172            .map(|identifier| identifier.value.as_str())
173            .collect();
174        if names.is_subset(&covered) {
175            return Ok(Some(SupersededBy {
176                order_id: candidate.id.to_string(),
177                cert_serial: candidate.cert_serial.clone().unwrap_or_default(),
178                not_after: candidate.cert_not_after.unwrap_or_default(),
179                via: "identifiers".to_string(),
180            }));
181        }
182    }
183
184    Ok(None)
185}
186
187/// Annotates a whole listing with [`days_remaining`] and [`superseded_by`].
188///
189/// The per-account cache is load-bearing rather than an optimisation.
190/// [`Order::find_by_account`] is unbounded, and the identifier signal needs it
191/// per row: a fifty-row page over one account read that account's entire order
192/// history fifty times, and `order list --expiring-in` is unpaged, so the same
193/// shape over a year-old CA is arbitrarily worse. One read per *distinct*
194/// account is the same answer for a bounded amount of work.
195///
196/// The `replaces` signal stays per row: it is a keyed lookup and a chain parse,
197/// and there is nothing to share between two rows.
198pub async fn annotate_expiring(
199    orders: Vec<Order>,
200    database: &Database,
201) -> Result<Vec<ExpiringEntry>, sqlx::Error> {
202    let now = now_secs();
203    let mut by_account: HashMap<Uuid, Vec<Order>> = HashMap::new();
204    let mut entries = Vec::with_capacity(orders.len());
205
206    for order in orders {
207        if let std::collections::hash_map::Entry::Vacant(slot) = by_account.entry(order.account_id)
208        {
209            slot.insert(Order::find_by_account(order.account_id, database).await?);
210        }
211        // Present by construction — inserted directly above when absent, so
212        // the empty slice is unreachable rather than a fallback.
213        let candidates = by_account
214            .get(&order.account_id)
215            .map_or(&[][..], Vec::as_slice);
216        let superseded = superseded_by(&order, candidates, database).await?;
217        entries.push(ExpiringEntry {
218            days_remaining: days_remaining(order.cert_not_after.unwrap_or_default(), now),
219            superseded_by: superseded,
220            order,
221        });
222    }
223
224    Ok(entries)
225}
226
227/// One page of expiring certificates, annotated, with the unpaged total and
228/// the number of rows this page suppressed.
229///
230/// **`total` counts the window, not the answer, and that is a limit worth
231/// stating rather than papering over.** Supersession is computed in Rust — two
232/// queries and an X.509 parse per row — so `include_superseded = false` cannot
233/// become a SQL predicate and the `COUNT(*)` beside the page cannot shrink to
234/// match it. The third member is therefore how many rows *this page* hid, and
235/// both front ends show both numbers. The alternative was a pager whose
236/// arithmetic quietly disagreed with the rows under it, which is the one bug a
237/// page control makes visible and nothing else does.
238pub async fn list_expiring(
239    query: &ExpiringQuery,
240    database: Arc<Database>,
241) -> Result<(Vec<ExpiringEntry>, i64, i64), sqlx::Error> {
242    let (orders, total) = Order::find_expiring(
243        query.profile.as_deref(),
244        query.before,
245        query.limit,
246        query.offset,
247        &database,
248    )
249    .await?;
250    let entries = annotate_expiring(orders, &database).await?;
251
252    if query.include_superseded {
253        return Ok((entries, total, 0));
254    }
255    // Named for what it counts, not for `query.before`, which is the horizon.
256    let annotated = i64::try_from(entries.len()).unwrap_or(i64::MAX);
257    let kept: Vec<ExpiringEntry> = entries
258        .into_iter()
259        .filter(|entry| entry.superseded_by.is_none())
260        .collect();
261    let hidden = annotated.saturating_sub(i64::try_from(kept.len()).unwrap_or(i64::MAX));
262    Ok((kept, total, hidden))
263}
264
265/// The RFC 9773 certID of a stored chain's leaf, for the `replaces` lookup.
266fn ari_cert_id(chain: &str) -> Option<String> {
267    acme_proxy_core::cert::leaf_der_from_chain(chain)
268        .ok()
269        .and_then(|der| acme_proxy_core::cert::ari_cert_id(&der).ok())
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::testutil::{account_id, issued_order};
276    use acme_proxy_core::identifier::Identifier;
277
278    const DAY: i64 = 24 * 60 * 60;
279
280    async fn db() -> Arc<Database> {
281        Arc::new(Database::connect_for_test().await.unwrap())
282    }
283
284    /// An order with a chain a certID can be derived from, on `default`.
285    async fn issued(
286        db: &Database,
287        account: uuid::Uuid,
288        names: &[&str],
289        not_after_days: i64,
290    ) -> Order {
291        issued_order(db, "default", account, names, not_after_days).await
292    }
293
294    /// [`superseded_by`] with the candidate list it would fetch for itself —
295    /// what a caller holding one order and no cache does.
296    async fn annotation(order: &Order, db: &Database) -> Option<SupersededBy> {
297        let candidates = Order::find_by_account(order.account_id, db).await.unwrap();
298        superseded_by(order, &candidates, db).await.unwrap()
299    }
300
301    /// The client said it renewed (RFC 9773 §5).
302    #[tokio::test]
303    async fn a_replaces_claim_marks_the_predecessor_superseded() {
304        let db = db().await;
305        let acct = account_id(&db).await;
306        let old = issued(&db, acct, &["a.example.com"], 3).await;
307
308        let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
309        let successor = issued(&db, acct, &["a.example.com"], 90).await;
310        crate::sql::query("UPDATE orders SET replaces = ? WHERE id = ?;")
311            .bind(&cert_id)
312            .bind(successor.id)
313            .execute(&*db)
314            .await
315            .unwrap();
316
317        let reloaded = Order::find_by_id(old.id.to_string().as_str(), &db)
318            .await
319            .unwrap()
320            .unwrap();
321        let superseded = annotation(&reloaded, &db).await.unwrap();
322        assert_eq!(superseded.order_id, successor.id.to_string());
323        assert_eq!(superseded.via, "replaces");
324    }
325
326    /// **A `replaces` claim from an order that never issued anything replaces
327    /// nothing.** `find_by_replaces` excludes only `invalid`, because its own
328    /// question is whether the claim is held; here a pending claim would
329    /// silence the one certificate still doing the work.
330    #[tokio::test]
331    async fn a_pending_replaces_claim_supersedes_nothing() {
332        let db = db().await;
333        let acct = account_id(&db).await;
334        let old = issued(&db, acct, &["a.example.com"], 3).await;
335        let cert_id = ari_cert_id(old.certificate.as_deref().unwrap()).unwrap();
336
337        // A claim on the predecessor, from an order with no certificate.
338        let pending = Order::create(
339            "default",
340            acct,
341            vec![Identifier::dns("a.example.com")],
342            now_secs() + 3600,
343            None,
344            None,
345            &db,
346        )
347        .await
348        .unwrap();
349        crate::sql::query("UPDATE orders SET replaces = ? WHERE id = ?;")
350            .bind(&cert_id)
351            .bind(pending.id)
352            .execute(&*db)
353            .await
354            .unwrap();
355
356        let reloaded = Order::find_by_id(old.id.to_string().as_str(), &db)
357            .await
358            .unwrap()
359            .unwrap();
360        assert!(annotation(&reloaded, &db).await.is_none());
361    }
362
363    /// The inference: a later certificate covering the same names.
364    #[tokio::test]
365    async fn a_later_certificate_over_the_same_names_supersedes() {
366        let db = db().await;
367        let acct = account_id(&db).await;
368        let old = issued(&db, acct, &["a.example.com"], 3).await;
369        let new = issued(&db, acct, &["a.example.com", "b.example.com"], 90).await;
370
371        let superseded = annotation(&old, &db).await.unwrap();
372        assert_eq!(superseded.order_id, new.id.to_string());
373        assert_eq!(
374            superseded.via, "identifiers",
375            "a superset covers these names, so it is a renewal"
376        );
377    }
378
379    /// The three the inference must **not** draw. Each would silence a
380    /// certificate that really is about to lapse, which is the failure this
381    /// whole annotation is written conservatively to avoid.
382    #[tokio::test]
383    async fn a_partial_a_revoked_and_another_accounts_certificate_supersede_nothing() {
384        let db = db().await;
385        let acct = account_id(&db).await;
386        let old = issued(&db, acct, &["a.example.com", "b.example.com"], 3).await;
387
388        // Covers only some of the names: the rest would go uncovered.
389        issued(&db, acct, &["a.example.com"], 90).await;
390        assert!(
391            annotation(&old, &db).await.is_none(),
392            "a subset is not a renewal"
393        );
394
395        // Covers them all, but has itself been withdrawn.
396        let mut revoked = issued(&db, acct, &["a.example.com", "b.example.com"], 90).await;
397        revoked.revoke(Some(1), &db).await.unwrap();
398        assert!(
399            annotation(&old, &db).await.is_none(),
400            "a revoked certificate covers nothing"
401        );
402
403        // Covers them all and is live, but belongs to somebody else.
404        let (other, _created) = crate::account::Account::find_or_create(
405            "default",
406            b"other-key",
407            Vec::new(),
408            &acme_proxy_core::audit::ClientContext::default(),
409            &db,
410        )
411        .await
412        .unwrap();
413        issued(&db, other.id, &["a.example.com", "b.example.com"], 90).await;
414        assert!(
415            annotation(&old, &db).await.is_none(),
416            "another subscriber's certificate is not this one's renewal"
417        );
418    }
419
420    /// The two boundaries the three surfaces share. Computed once, here, so
421    /// the digest, the panel and the terminal cannot disagree about what
422    /// "within 7 days" means.
423    #[test]
424    fn the_horizon_and_the_day_count_agree_on_a_whole_day() {
425        let now = now_secs();
426        assert!((expiring_horizon(7) - now - 7 * DAY).abs() <= 1);
427        // Saturating rather than overflowing: `--expiring-in` takes a `u64`
428        // and nothing bounds what an operator types.
429        assert_eq!(expiring_horizon(u64::MAX), i64::MAX);
430
431        // Floored, never rounded: an operator told "4 days" about a
432        // certificate that lapses in three and a half has been told the wrong
433        // week.
434        assert_eq!(days_remaining(1_000 + 3 * DAY + DAY / 2, 1_000), 3);
435        assert_eq!(days_remaining(1_000 + DAY - 1, 1_000), 0);
436        // Never negative: one that lapsed between the query and here is "0
437        // days", not "-1".
438        assert_eq!(days_remaining(1_000, 1_000 + 5 * DAY), 0);
439        assert_eq!(days_remaining(i64::MIN, i64::MAX), 0);
440    }
441
442    /// The panel needs a window even where the digest is switched off, which
443    /// `lead_days = 0` is.
444    #[test]
445    fn the_default_window_falls_back_only_when_the_digest_is_off() {
446        let mut config = Config::default();
447        assert_eq!(config.notify.expiry.lead_days, 0, "off by default");
448        assert_eq!(default_lead_days(&config), DEFAULT_LEAD_DAYS);
449
450        config.notify.expiry.lead_days = 3;
451        assert_eq!(
452            default_lead_days(&config),
453            3,
454            "a deployment that chose a lead time gets it"
455        );
456    }
457
458    /// One `find_by_account` per *distinct* account, not per row.
459    ///
460    /// The listing is unpaged from `order list --expiring-in`, and that query
461    /// is unbounded, so the un-cached shape reads a busy account's whole order
462    /// history once per certificate it holds. Asserted through the annotation
463    /// staying correct across a page where one account holds several rows —
464    /// the cache is only safe if a candidate list is the same answer for every
465    /// row of the account it belongs to.
466    #[tokio::test]
467    async fn a_listing_reads_each_accounts_orders_once_and_still_annotates_each_row() {
468        let db = db().await;
469        let acct = account_id(&db).await;
470
471        let a = issued(&db, acct, &["a.example.com"], 3).await;
472        let b = issued(&db, acct, &["b.example.com"], 5).await;
473        // Renews `a` only. `b` must stay un-annotated even though it shares
474        // the cached candidate list that contains this row.
475        let renewal = issued(&db, acct, &["a.example.com"], 90).await;
476
477        let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
478            .await
479            .unwrap();
480        let entries = annotate_expiring(orders, &db).await.unwrap();
481
482        let annotated = |id: &str| -> Option<SupersededBy> {
483            entries
484                .iter()
485                .find(|entry| entry.order.id.to_string() == id)
486                .and_then(|entry| entry.superseded_by.clone())
487        };
488        assert_eq!(
489            annotated(a.id.to_string().as_str()).unwrap().order_id,
490            renewal.id.to_string()
491        );
492        assert!(
493            annotated(b.id.to_string().as_str()).is_none(),
494            "a shared candidate list must not leak one row's renewal onto another"
495        );
496        // And the days came out of the same helper the digest uses. Stamped
497        // half a day past the three so the assertion distinguishes a floor
498        // from a round without racing the clock at the boundary.
499        Order::set_cert_not_after(a.id, now_secs() + 3 * DAY + DAY / 2, &db)
500            .await
501            .unwrap();
502        let (orders, _total) = Order::find_expiring(None, expiring_horizon(30), 50, 0, &db)
503            .await
504            .unwrap();
505        let entries = annotate_expiring(orders, &db).await.unwrap();
506        let a_entry = entries.iter().find(|e| e.order.id == a.id).unwrap();
507        assert_eq!(a_entry.days_remaining, 3, "floored, not rounded");
508    }
509
510    /// `include_superseded` hides rows from the *page* and says how many, and
511    /// deliberately leaves `total` alone — the annotation is not a SQL
512    /// predicate, so the count beside the page cannot follow it down.
513    #[tokio::test]
514    async fn hiding_superseded_rows_reports_the_count_rather_than_shrinking_the_total() {
515        let db = db().await;
516        let acct = account_id(&db).await;
517        let a = issued(&db, acct, &["a.example.com"], 3).await;
518        issued(&db, acct, &["b.example.com"], 5).await;
519        issued(&db, acct, &["a.example.com"], 90).await;
520
521        let query = |include: bool| ExpiringQuery {
522            profile: None,
523            before: expiring_horizon(30),
524            include_superseded: include,
525            limit: 50,
526            offset: 0,
527        };
528
529        let (shown, total, hidden) = list_expiring(&query(true), db.clone()).await.unwrap();
530        assert_eq!(shown.len(), 2, "both expiring rows, annotated");
531        assert_eq!(total, 2);
532        assert_eq!(hidden, 0);
533
534        let (kept, total, hidden) = list_expiring(&query(false), db.clone()).await.unwrap();
535        assert_eq!(kept.len(), 1);
536        assert!(kept.iter().all(|entry| entry.superseded_by.is_none()));
537        assert_ne!(kept[0].order.id, a.id, "the replaced row is the one hidden");
538        assert_eq!(hidden, 1);
539        assert_eq!(
540            total, 2,
541            "the total counts the window, not the answer -- documented on list_expiring"
542        );
543    }
544
545    /// The profile filter reaches through the operation layer, and the ordering
546    /// is the query's: soonest first.
547    #[tokio::test]
548    async fn the_listing_scopes_by_profile_and_answers_soonest_first() {
549        let db = db().await;
550        let acct = account_id(&db).await;
551        let here = issued_order(&db, "default", acct, &["a.example.com"], 5).await;
552        let sooner = issued_order(&db, "default", acct, &["b.example.com"], 2).await;
553        issued_order(&db, "other", acct, &["c.example.com"], 1).await;
554
555        let scoped = ExpiringQuery {
556            profile: Some("default".to_string()),
557            before: expiring_horizon(30),
558            include_superseded: true,
559            limit: 50,
560            offset: 0,
561        };
562        let (entries, total, _) = list_expiring(&scoped, db.clone()).await.unwrap();
563        let ids: Vec<String> = entries
564            .iter()
565            .map(|entry| entry.order.id.to_string())
566            .collect();
567        assert_eq!(ids, vec![sooner.id.to_string(), here.id.to_string()]);
568        assert_eq!(total, 2);
569
570        let unscoped = ExpiringQuery {
571            profile: None,
572            ..scoped
573        };
574        let (entries, total, _) = list_expiring(&unscoped, db).await.unwrap();
575        assert_eq!(entries.len(), 3);
576        assert_eq!(total, 3);
577    }
578}