Skip to main content

acme_proxy/admin/
render.rs

1//! The JSON renderings, shared by both front ends.
2//!
3//! One shape per admin resource, and **the boundary this module exists to
4//! hold**: everything here is read by the CLI's `--json` branches *and* by
5//! `src/webadmin/`, so a change is a change to a wire format two callers parse.
6//! The human-readable renderings are the CLI's alone and live in
7//! [`crate::cli::render`], which is where colour is woven in — none of it can
8//! reach this file, so `--json` output stays byte-identical whatever the
9//! terminal is.
10//!
11//! Each surfaces the admin-only fields the ACME wire format deliberately does
12//! not carry (an order's revocation, an account's traceability columns), and
13//! each **omits** rather than nulls an absent one, so a template asking
14//! `{% if account.createdIp %}` is asking the question it looks like it is
15//! asking.
16
17use base64::prelude::*;
18use serde_json::Value;
19use uuid::Uuid;
20
21use crate::admin::ops::{ExpiringEntry, OrderDetail};
22use crate::sqlite::account::{Account, pubkey_fingerprint};
23use crate::sqlite::admin_session::AdminSession;
24use crate::sqlite::admin_user::AdminUser;
25use crate::sqlite::eab::Eab;
26use crate::sqlite::order::{Order, rfc3339};
27
28/// The public base URL of one endpoint, as the server itself derives it.
29///
30/// Admin output is rendered from `server.base_url`, which names the process,
31/// not an endpoint — every URL a client was ever handed carries the owning
32/// profile's prefix, so this puts it back.
33#[must_use]
34pub fn profile_base_url(base_url: &str, profile: &str) -> String {
35    format!(
36        "{}{}/{profile}",
37        base_url.trim_end_matches('/'),
38        crate::PROFILE_PREFIX
39    )
40}
41
42/// JSON representation for account admin display.
43///
44/// The traceability members are admin-only: [`Account::to_json`] is the RFC 8555
45/// object and deliberately carries none of them. Each is **omitted** rather than
46/// rendered as `null` when the column is unset, so a template asking `{% if
47/// account.createdIp %}` is asking the question it looks like it is asking.
48#[must_use]
49pub fn render_account_json(account: &Account, base_url: &str) -> Value {
50    let mut object = account
51        .to_json(&profile_base_url(base_url, &account.profile))
52        .as_object()
53        .cloned()
54        .unwrap_or_default();
55    object.insert("id".to_string(), Value::String(account.id.to_string()));
56    object.insert(
57        "profile".to_string(),
58        Value::String(account.profile.clone()),
59    );
60    object.insert(
61        "createdAt".to_string(),
62        Value::String(rfc3339(account.created_at)),
63    );
64    object.insert(
65        "pubkeyFingerprint".to_string(),
66        Value::String(pubkey_fingerprint(&account.pubkey)),
67    );
68    if let Some(seen) = account.last_seen_at {
69        object.insert("lastSeenAt".to_string(), Value::String(rfc3339(seen)));
70    }
71    for (key, value) in [
72        ("createdIp", account.created_ip.as_ref()),
73        ("createdPtr", account.created_ptr.as_ref()),
74        ("lastSeenIp", account.last_seen_ip.as_ref()),
75        ("lastSeenPtr", account.last_seen_ptr.as_ref()),
76    ] {
77        if let Some(value) = value {
78            object.insert(key.to_string(), Value::String(value.clone()));
79        }
80    }
81    Value::Object(object)
82}
83
84/// JSON representation for order admin display.
85#[must_use]
86pub fn render_order_json(order: &Order, base_url: &str, authz_ids: &[Uuid]) -> Value {
87    let mut object = order
88        .to_json(&profile_base_url(base_url, &order.profile), authz_ids)
89        .as_object()
90        .cloned()
91        .unwrap_or_default();
92    object.insert("id".to_string(), Value::String(order.id.to_string()));
93    object.insert("profile".to_string(), Value::String(order.profile.clone()));
94    // Admin-only, like `id` and `profile`: the ACME order object deliberately
95    // never names its account, but an operator looking at an order almost
96    // always wants to get to the account behind it, and without this neither
97    // front end can offer that link.
98    object.insert(
99        "accountId".to_string(),
100        Value::String(order.account_id.to_string()),
101    );
102    object.insert(
103        "createdAt".to_string(),
104        Value::String(rfc3339(order.created_at)),
105    );
106    // The leaf's serial, admin-only for `accountId`'s reason and absent from
107    // every rendering until now — while `audit list --cert-serial` and
108    // `GET /api/audit?certSerial=` both filter on it, so an operator could
109    // search the trail by a value nothing would tell them. Omitted rather than
110    // nulled: an unissued order has no serial, which is a different statement
111    // from an empty one. `render_expiring_json` below deliberately keeps its
112    // `unwrap_or_default()` — that shape is the digest's own, member for
113    // member, and is not this one.
114    if let Some(serial) = order.cert_serial.as_ref() {
115        object.insert("certSerial".to_string(), Value::String(serial.clone()));
116    }
117    // The leaf's own expiry, and admin-only for `accountId`'s reason: RFC 8555
118    // gives the order object no member for it, and the `notAfter` already in
119    // there from `to_json` is the *requested* §7.4 window, which is a different
120    // question with a confusingly similar name. Omitted rather than nulled,
121    // like every other member here — a row issued before the column existed has
122    // nothing to say yet, and the negative sentinel means the chain would not
123    // parse, which is not a date to render.
124    if let Some(not_after) = order.cert_not_after.filter(|value| *value >= 0) {
125        object.insert(
126            "certNotAfter".to_string(),
127            Value::String(rfc3339(not_after)),
128        );
129    }
130    if let Some(revoked_at) = order.revoked_at {
131        object.insert("revokedAt".to_string(), Value::String(rfc3339(revoked_at)));
132        if let Some(reason) = order.revocation_reason {
133            object.insert("revocationReason".to_string(), Value::from(reason));
134        }
135    }
136    Value::Object(object)
137}
138
139/// `order show --json` JSON output.
140#[must_use]
141pub fn render_order_detail_json(detail: &OrderDetail, base_url: &str) -> Value {
142    let authz_ids: Vec<Uuid> = detail.authorizations.iter().map(|(a, _)| a.id).collect();
143    let profile_base = profile_base_url(base_url, &detail.order.profile);
144    let authorizations: Vec<Value> = detail
145        .authorizations
146        .iter()
147        .map(|(a, c)| a.to_json(&profile_base, c))
148        .collect();
149    let mut order = render_order_json(&detail.order, base_url, &authz_ids);
150    // The issued chain itself, admin-only and **detail-only**.
151    //
152    // `certificate` beside it is the ACME *URL*, reachable only by signed
153    // POST-as-GET — a browser following it gets nothing, so on its own it is a
154    // dead string on the order card. The PEM is the thing an operator actually
155    // wants, and it is already in the row.
156    //
157    // Deliberately not in `render_order_json`, which also renders every row of
158    // every listing: a page of fifty orders would carry fifty chains for a
159    // field no list can show.
160    if let (Some(object), Some(pem)) = (order.as_object_mut(), detail.order.certificate.as_ref()) {
161        object.insert("certificatePem".to_string(), Value::String(pem.clone()));
162    }
163
164    let mut root = serde_json::Map::new();
165    root.insert("order".to_string(), order);
166    root.insert("authorizations".to_string(), Value::Array(authorizations));
167    Value::Object(root)
168}
169
170/// One row of the expiry list: `GET /api/expiring`, `/ui/expiring` and
171/// `order list --expiring-in --json`.
172///
173/// A shape of its own rather than [`render_order_json`] plus two members, for
174/// two reasons. That renderer takes `authz_ids`, which no expiry view shows and
175/// which would be a query per row to supply; and this shape is deliberately the
176/// digest's own (`crate::notify::ExpiringCertificate`), so the mail, the page,
177/// the API and the terminal all describe an expiring certificate the same way.
178///
179/// `supersededBy` is **omitted** when nothing has replaced this certificate,
180/// like every other absent member here — and an operator scanning the list is
181/// looking for exactly the rows where it is absent, so `null` would be a value
182/// where the question is presence.
183#[must_use]
184pub fn render_expiring_json(entry: &ExpiringEntry) -> Value {
185    let order = &entry.order;
186    let mut object = serde_json::Map::new();
187    object.insert("orderId".to_string(), Value::String(order.id.to_string()));
188    object.insert("profile".to_string(), Value::String(order.profile.clone()));
189    object.insert(
190        "accountId".to_string(),
191        Value::String(order.account_id.to_string()),
192    );
193    object.insert(
194        "certSerial".to_string(),
195        Value::String(order.cert_serial.clone().unwrap_or_default()),
196    );
197    object.insert(
198        "identifiers".to_string(),
199        Value::Array(
200            order
201                .identifiers
202                .iter()
203                .map(|identifier| Value::String(identifier.value.clone()))
204                .collect(),
205        ),
206    );
207    object.insert(
208        "notAfter".to_string(),
209        Value::String(rfc3339(order.cert_not_after.unwrap_or_default())),
210    );
211    object.insert(
212        "daysRemaining".to_string(),
213        Value::from(entry.days_remaining),
214    );
215    if let Some(superseded) = &entry.superseded_by {
216        object.insert(
217            "supersededBy".to_string(),
218            serde_json::json!({
219                "orderId": superseded.order_id,
220                "certSerial": superseded.cert_serial,
221                "notAfter": rfc3339(superseded.not_after),
222                "via": superseded.via,
223            }),
224        );
225    }
226    Value::Object(object)
227}
228
229/// `eab list --json` / `eab show --json`.
230#[must_use]
231pub fn render_eab_json(eab: &Eab) -> Value {
232    eab.to_json()
233}
234
235/// `eab create` JSON output (includes secret).
236#[must_use]
237pub fn render_eab_created_json(eab: &Eab) -> Value {
238    let mut object = eab.to_json().as_object().cloned().unwrap_or_default();
239    object.insert(
240        "hmacKey".to_string(),
241        Value::String(BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)),
242    );
243    Value::Object(object)
244}
245
246/// `admin user list --json`.
247///
248/// A straight pass-through: unlike an account or an order, an operator has no
249/// ACME wire form to augment -- [`AdminUser::to_json`] is already the only
250/// representation, and it is the one that omits the password hash.
251#[must_use]
252pub fn render_admin_user_json(user: &AdminUser) -> Value {
253    user.to_json()
254}
255
256/// `admin user show --json`: the listing shape plus the two members that cost a
257/// query.
258///
259/// [`render_order_detail_json`]'s arrangement, and for its reason.
260/// `enrolmentPending` distinguishes the state a listing cannot show --
261/// "enrolment started, never confirmed" behaves exactly like "no factor" at the
262/// login prompt, so an operator who believes they enrolled has no other way to
263/// find out -- and `recoveryCodesRemaining` is a `COUNT` on a second table,
264/// which a page of fifty operators should not pay fifty times.
265#[must_use]
266pub fn render_admin_user_detail_json(user: &AdminUser, recovery_codes_remaining: i64) -> Value {
267    let mut object = render_admin_user_json(user)
268        .as_object()
269        .cloned()
270        .unwrap_or_default();
271    object.insert(
272        "enrolmentPending".to_string(),
273        Value::Bool(user.has_pending_totp()),
274    );
275    object.insert(
276        "recoveryCodesRemaining".to_string(),
277        Value::from(recovery_codes_remaining),
278    );
279    Value::Object(object)
280}
281
282/// `admin session list --json`.
283#[must_use]
284pub fn render_admin_session_json(session: &AdminSession) -> Value {
285    session.to_json()
286}
287
288/// `nonce count --json` and `GET /api/nonces`.
289///
290/// A count and nothing else: a nonce is a bearer credential until it is
291/// consumed, so listing values would put live ones on a screen. The count is
292/// the useful part -- it should sit near the request rate times the TTL, and a
293/// number far above that says the reaper is not running, which is why the TTL
294/// travels beside it rather than leaving the reader to go and look it up.
295#[must_use]
296pub fn render_nonce_stats_json(count: i64, ttl_seconds: u64) -> Value {
297    serde_json::json!({
298        "count": count,
299        "ttlSeconds": ttl_seconds,
300    })
301}
302
303/// One ACME endpoint, as every surface describes it.
304///
305/// The two front ends reach this from opposite directions, and the difference
306/// is real rather than an implementation detail. `GET /api/profiles` and
307/// `/ui/profiles` build it from a **mounted** [`crate::Profile`], so they
308/// describe what this process is actually serving; `acme-proxy profile list`
309/// builds it from the configuration, because the alternative is
310/// `Profile::build_all`, which constructs signer backends -- generating a CA
311/// key and contacting a relay upstream for a read-only listing. That is
312/// `filter show`'s split exactly: the panel serves the live thing, the terminal
313/// rebuilds one, and between an edit and its `SIGHUP` the two legitimately
314/// disagree.
315pub struct ProfileSummary {
316    pub name: String,
317    pub base_url: String,
318    pub challenge_bypass: bool,
319    pub eab_enabled: bool,
320}
321
322impl ProfileSummary {
323    /// An endpoint this process is serving.
324    #[must_use]
325    pub fn mounted(profile: &crate::Profile) -> Self {
326        Self {
327            name: profile.name.clone(),
328            base_url: profile.base_url.clone(),
329            challenge_bypass: profile.challenges.is_bypassed(),
330            eab_enabled: profile.eab.enabled,
331        }
332    }
333
334    /// An endpoint this configuration would mount.
335    ///
336    /// `Config::resolve_profiles` has already dropped anything `enabled = false`
337    /// on the caller's behalf, so this needs no filter of its own -- the list it
338    /// is mapped over is already the mounted set, minus the fact of being
339    /// mounted.
340    #[must_use]
341    pub fn configured(base_url: &str, profile: &crate::config::ProfileConfig) -> Self {
342        Self {
343            name: profile.name.clone(),
344            base_url: profile_base_url(base_url, &profile.name),
345            challenge_bypass: profile.sections.challenge.bypass,
346            eab_enabled: profile.sections.eab.enabled,
347        }
348    }
349
350    /// Where a client fetches this endpoint's directory (RFC 8555 §7.1.1).
351    #[must_use]
352    pub fn directory_url(&self) -> String {
353        format!("{}{}", self.base_url, crate::routes::DIRECTORY)
354    }
355}
356
357/// `profile list --json`, `GET /api/profiles` and `/ui/profiles`.
358#[must_use]
359pub fn render_profile_json(profile: &ProfileSummary) -> Value {
360    serde_json::json!({
361        "name": profile.name,
362        "baseUrl": profile.base_url,
363        "directory": profile.directory_url(),
364        "challengeBypass": profile.challenge_bypass,
365        "eabEnabled": profile.eab_enabled,
366    })
367}
368
369#[cfg(test)]
370mod tests {
371    use std::sync::Arc;
372
373    use super::*;
374    use crate::admin::ops::load_order_detail;
375    use crate::audit::ClientContext;
376    use crate::sqlite::authz::{Authorization, Challenge};
377    use crate::sqlite::db::Database;
378    use crate::sqlite::order::Identifier;
379    use crate::sqlite::status::OrderStatus;
380    use crate::testutil::{
381        account_id, account_seen_from, admin_session_fixture, admin_user_fixture, client_context,
382        order_fixture,
383    };
384
385    #[tokio::test]
386    async fn render_account_json_includes_id_and_base_fields() {
387        let db = Arc::new(Database::connect_in_memory().await.unwrap());
388        let account = account_seen_from(
389            &[1u8, 2, 3],
390            &client_context(Some("203.0.113.7"), Some("host.example.com")),
391            &db,
392        )
393        .await;
394
395        let json = render_account_json(&account, "http://localhost:3000");
396        assert_eq!(json["id"], account.id.to_string());
397        assert_eq!(json["status"], "valid");
398        assert!(
399            json["orders"]
400                .as_str()
401                .unwrap()
402                .contains(&account.id.to_string())
403        );
404        assert!(json["pubkeyFingerprint"].is_string());
405        assert_eq!(json["createdIp"], "203.0.113.7");
406        assert_eq!(json["createdPtr"], "host.example.com");
407        assert_eq!(json["lastSeenIp"], "203.0.113.7");
408        assert_eq!(json["lastSeenPtr"], "host.example.com");
409        assert!(json["lastSeenAt"].as_str().unwrap().contains('T'));
410    }
411
412    /// Absent, not `null`: a template asking `{% if account.createdIp %}` must
413    /// be asking the question it looks like it is asking.
414    #[tokio::test]
415    async fn render_account_json_omits_the_traceability_members_it_has_no_value_for() {
416        let db = Arc::new(Database::connect_in_memory().await.unwrap());
417        let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
418
419        let json = render_account_json(&account, "http://localhost:3000");
420        let object = json.as_object().unwrap();
421        for key in ["createdIp", "createdPtr", "lastSeenIp", "lastSeenPtr"] {
422            assert!(!object.contains_key(key), "{key} in {json}");
423        }
424    }
425
426    #[test]
427    fn render_order_json_includes_id_and_authorizations() {
428        let account = crate::sqlite::id::mint();
429        let authz = crate::sqlite::id::mint();
430        let order = order_fixture(account, OrderStatus::Pending);
431        let json = render_order_json(&order, "http://localhost:3000", &[authz]);
432        assert_eq!(json["id"], order.id.to_string());
433        // Admin output is rendered against the *profile's* base URL, not the
434        // server's: that is the URL the client was handed.
435        assert_eq!(json["profile"], "default");
436        // Admin-only, and absent from the ACME order object: without it
437        // neither front end could link an order back to its account.
438        assert_eq!(json["accountId"], account.to_string());
439        assert_eq!(
440            json["authorizations"],
441            serde_json::json!([format!(
442                "http://localhost:3000/profile/default/authz/{authz}"
443            )])
444        );
445    }
446
447    #[tokio::test]
448    async fn render_order_detail_json_nests_authorizations_and_challenges() {
449        let db = Arc::new(Database::connect_in_memory().await.unwrap());
450        let acct = account_id(&db).await;
451        let order = Order::create(
452            "default",
453            acct,
454            vec![Identifier::dns("example.com")],
455            crate::sqlite::nonce::now_secs() + 3600,
456            None,
457            None,
458            &db,
459        )
460        .await
461        .unwrap();
462        let authz = Authorization::create(
463            order.id,
464            Identifier::dns("example.com"),
465            crate::sqlite::nonce::now_secs() + 3600,
466            &db,
467        )
468        .await
469        .unwrap();
470        Challenge::create(authz.id, "http-01", &db).await.unwrap();
471
472        let detail = load_order_detail(order.id.to_string().as_str(), db)
473            .await
474            .unwrap()
475            .unwrap();
476        let json = render_order_detail_json(&detail, "http://localhost:3000");
477        assert_eq!(json["order"]["id"], order.id.to_string());
478        assert_eq!(json["authorizations"].as_array().unwrap().len(), 1);
479        assert_eq!(
480            json["authorizations"][0]["challenges"]
481                .as_array()
482                .unwrap()
483                .len(),
484            1
485        );
486    }
487
488    #[tokio::test]
489    async fn render_eab_created_json_includes_the_hmac_key_and_line_json_does_not() {
490        let db = Arc::new(Database::connect_in_memory().await.unwrap());
491        let eab = Eab::create(None, None, &db).await.unwrap();
492
493        let created = render_eab_created_json(&eab);
494        assert!(created["hmacKey"].is_string());
495
496        let listed = render_eab_json(&eab);
497        assert!(listed.get("hmacKey").is_none());
498    }
499
500    #[test]
501    fn render_order_json_revoked_includes_reason_and_time() {
502        let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
503        order.revoked_at = Some(1700000000);
504        order.revocation_reason = Some(1);
505        let json = render_order_json(&order, "http://localhost:3000", &[]);
506        assert_eq!(json["revokedAt"].as_str().unwrap().len(), 20);
507        assert_eq!(json["revocationReason"], 1);
508    }
509
510    #[test]
511    fn render_order_json_carries_the_cert_serial_and_omits_it_when_unissued() {
512        // The complaint this member answers: `audit list --cert-serial` and
513        // `GET /api/audit?certSerial=` both filter on this value, and until now
514        // no order rendering would tell an operator what it was.
515        let mut order = order_fixture(crate::sqlite::id::mint(), OrderStatus::Valid);
516        order.cert_serial = Some("03a7f1c9".to_string());
517        let json = render_order_json(&order, "http://localhost:3000", &[]);
518        assert_eq!(json["certSerial"], "03a7f1c9");
519
520        // Omitted, not nulled: an order that never issued has no serial, which
521        // is a different statement from an empty one.
522        let unissued = order_fixture(crate::sqlite::id::mint(), OrderStatus::Pending);
523        let json = render_order_json(&unissued, "http://localhost:3000", &[]);
524        assert!(json.get("certSerial").is_none());
525    }
526
527    #[test]
528    fn render_admin_user_json_omits_every_secret() {
529        let mut user = admin_user_fixture();
530        user.totp_secret = Some(vec![9, 9, 9]);
531        let json = render_admin_user_json(&user);
532        let rendered = json.to_string();
533        assert!(!rendered.contains("pbkdf2"));
534        assert!(!rendered.contains("totpSecret"));
535        assert_eq!(json["username"], "alice");
536        assert_eq!(json["totpEnabled"], true);
537    }
538
539    #[test]
540    fn render_admin_session_json_omits_the_hash_and_the_csrf_token() {
541        let json = render_admin_session_json(&admin_session_fixture());
542        let rendered = json.to_string();
543        assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
544        assert!(!rendered.contains("the-csrf-token"));
545        assert_eq!(json["id"], "01234567");
546        assert_eq!(json["state"], "active");
547    }
548}