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;
19
20use crate::admin::ops::{ExpiringEntry, OrderDetail};
21use crate::sqlite::account::{Account, pubkey_fingerprint};
22use crate::sqlite::admin_session::AdminSession;
23use crate::sqlite::admin_user::AdminUser;
24use crate::sqlite::eab::Eab;
25use crate::sqlite::order::{Order, rfc3339};
26
27/// The public base URL of one endpoint, as the server itself derives it.
28///
29/// Admin output is rendered from `server.base_url`, which names the process,
30/// not an endpoint — every URL a client was ever handed carries the owning
31/// profile's prefix, so this puts it back.
32#[must_use]
33pub fn profile_base_url(base_url: &str, profile: &str) -> String {
34    format!(
35        "{}{}/{profile}",
36        base_url.trim_end_matches('/'),
37        crate::PROFILE_PREFIX
38    )
39}
40
41/// JSON representation for account admin display.
42///
43/// The traceability members are admin-only: [`Account::to_json`] is the RFC 8555
44/// object and deliberately carries none of them. Each is **omitted** rather than
45/// rendered as `null` when the column is unset, so a template asking `{% if
46/// account.createdIp %}` is asking the question it looks like it is asking.
47#[must_use]
48pub fn render_account_json(account: &Account, base_url: &str) -> Value {
49    let mut object = account
50        .to_json(&profile_base_url(base_url, &account.profile))
51        .as_object()
52        .cloned()
53        .unwrap_or_default();
54    object.insert("id".to_string(), Value::String(account.id.clone()));
55    object.insert(
56        "profile".to_string(),
57        Value::String(account.profile.clone()),
58    );
59    object.insert(
60        "createdAt".to_string(),
61        Value::String(rfc3339(account.created_at)),
62    );
63    object.insert(
64        "pubkeyFingerprint".to_string(),
65        Value::String(pubkey_fingerprint(&account.pubkey)),
66    );
67    if let Some(seen) = account.last_seen_at {
68        object.insert("lastSeenAt".to_string(), Value::String(rfc3339(seen)));
69    }
70    for (key, value) in [
71        ("createdIp", account.created_ip.as_ref()),
72        ("createdPtr", account.created_ptr.as_ref()),
73        ("lastSeenIp", account.last_seen_ip.as_ref()),
74        ("lastSeenPtr", account.last_seen_ptr.as_ref()),
75    ] {
76        if let Some(value) = value {
77            object.insert(key.to_string(), Value::String(value.clone()));
78        }
79    }
80    Value::Object(object)
81}
82
83/// JSON representation for order admin display.
84#[must_use]
85pub fn render_order_json(order: &Order, base_url: &str, authz_ids: &[String]) -> Value {
86    let mut object = order
87        .to_json(&profile_base_url(base_url, &order.profile), authz_ids)
88        .as_object()
89        .cloned()
90        .unwrap_or_default();
91    object.insert("id".to_string(), Value::String(order.id.clone()));
92    object.insert("profile".to_string(), Value::String(order.profile.clone()));
93    // Admin-only, like `id` and `profile`: the ACME order object deliberately
94    // never names its account, but an operator looking at an order almost
95    // always wants to get to the account behind it, and without this neither
96    // front end can offer that link.
97    object.insert(
98        "accountId".to_string(),
99        Value::String(order.account_id.clone()),
100    );
101    object.insert(
102        "createdAt".to_string(),
103        Value::String(rfc3339(order.created_at)),
104    );
105    // The leaf's serial, admin-only for `accountId`'s reason and absent from
106    // every rendering until now — while `audit list --cert-serial` and
107    // `GET /api/audit?certSerial=` both filter on it, so an operator could
108    // search the trail by a value nothing would tell them. Omitted rather than
109    // nulled: an unissued order has no serial, which is a different statement
110    // from an empty one. `render_expiring_json` below deliberately keeps its
111    // `unwrap_or_default()` — that shape is the digest's own, member for
112    // member, and is not this one.
113    if let Some(serial) = order.cert_serial.as_ref() {
114        object.insert("certSerial".to_string(), Value::String(serial.clone()));
115    }
116    // The leaf's own expiry, and admin-only for `accountId`'s reason: RFC 8555
117    // gives the order object no member for it, and the `notAfter` already in
118    // there from `to_json` is the *requested* §7.4 window, which is a different
119    // question with a confusingly similar name. Omitted rather than nulled,
120    // like every other member here — a row issued before the column existed has
121    // nothing to say yet, and the negative sentinel means the chain would not
122    // parse, which is not a date to render.
123    if let Some(not_after) = order.cert_not_after.filter(|value| *value >= 0) {
124        object.insert(
125            "certNotAfter".to_string(),
126            Value::String(rfc3339(not_after)),
127        );
128    }
129    if let Some(revoked_at) = order.revoked_at {
130        object.insert("revokedAt".to_string(), Value::String(rfc3339(revoked_at)));
131        if let Some(reason) = order.revocation_reason {
132            object.insert("revocationReason".to_string(), Value::from(reason));
133        }
134    }
135    Value::Object(object)
136}
137
138/// `order show --json` JSON output.
139#[must_use]
140pub fn render_order_detail_json(detail: &OrderDetail, base_url: &str) -> Value {
141    let authz_ids: Vec<String> = detail
142        .authorizations
143        .iter()
144        .map(|(a, _)| a.id.clone())
145        .collect();
146    let profile_base = profile_base_url(base_url, &detail.order.profile);
147    let authorizations: Vec<Value> = detail
148        .authorizations
149        .iter()
150        .map(|(a, c)| a.to_json(&profile_base, c))
151        .collect();
152    let mut order = render_order_json(&detail.order, base_url, &authz_ids);
153    // The issued chain itself, admin-only and **detail-only**.
154    //
155    // `certificate` beside it is the ACME *URL*, reachable only by signed
156    // POST-as-GET — a browser following it gets nothing, so on its own it is a
157    // dead string on the order card. The PEM is the thing an operator actually
158    // wants, and it is already in the row.
159    //
160    // Deliberately not in `render_order_json`, which also renders every row of
161    // every listing: a page of fifty orders would carry fifty chains for a
162    // field no list can show.
163    if let (Some(object), Some(pem)) = (order.as_object_mut(), detail.order.certificate.as_ref()) {
164        object.insert("certificatePem".to_string(), Value::String(pem.clone()));
165    }
166
167    let mut root = serde_json::Map::new();
168    root.insert("order".to_string(), order);
169    root.insert("authorizations".to_string(), Value::Array(authorizations));
170    Value::Object(root)
171}
172
173/// One row of the expiry list: `GET /api/expiring`, `/ui/expiring` and
174/// `order list --expiring-in --json`.
175///
176/// A shape of its own rather than [`render_order_json`] plus two members, for
177/// two reasons. That renderer takes `authz_ids`, which no expiry view shows and
178/// which would be a query per row to supply; and this shape is deliberately the
179/// digest's own (`crate::notify::ExpiringCertificate`), so the mail, the page,
180/// the API and the terminal all describe an expiring certificate the same way.
181///
182/// `supersededBy` is **omitted** when nothing has replaced this certificate,
183/// like every other absent member here — and an operator scanning the list is
184/// looking for exactly the rows where it is absent, so `null` would be a value
185/// where the question is presence.
186#[must_use]
187pub fn render_expiring_json(entry: &ExpiringEntry) -> Value {
188    let order = &entry.order;
189    let mut object = serde_json::Map::new();
190    object.insert("orderId".to_string(), Value::String(order.id.clone()));
191    object.insert("profile".to_string(), Value::String(order.profile.clone()));
192    object.insert(
193        "accountId".to_string(),
194        Value::String(order.account_id.clone()),
195    );
196    object.insert(
197        "certSerial".to_string(),
198        Value::String(order.cert_serial.clone().unwrap_or_default()),
199    );
200    object.insert(
201        "identifiers".to_string(),
202        Value::Array(
203            order
204                .identifiers
205                .iter()
206                .map(|identifier| Value::String(identifier.value.clone()))
207                .collect(),
208        ),
209    );
210    object.insert(
211        "notAfter".to_string(),
212        Value::String(rfc3339(order.cert_not_after.unwrap_or_default())),
213    );
214    object.insert(
215        "daysRemaining".to_string(),
216        Value::from(entry.days_remaining),
217    );
218    if let Some(superseded) = &entry.superseded_by {
219        object.insert(
220            "supersededBy".to_string(),
221            serde_json::json!({
222                "orderId": superseded.order_id,
223                "certSerial": superseded.cert_serial,
224                "notAfter": rfc3339(superseded.not_after),
225                "via": superseded.via,
226            }),
227        );
228    }
229    Value::Object(object)
230}
231
232/// `eab list --json` / `eab show --json`.
233#[must_use]
234pub fn render_eab_json(eab: &Eab) -> Value {
235    eab.to_json()
236}
237
238/// `eab create` JSON output (includes secret).
239#[must_use]
240pub fn render_eab_created_json(eab: &Eab) -> Value {
241    let mut object = eab.to_json().as_object().cloned().unwrap_or_default();
242    object.insert(
243        "hmacKey".to_string(),
244        Value::String(BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)),
245    );
246    Value::Object(object)
247}
248
249/// `admin user list --json` / `admin user show --json`.
250///
251/// A straight pass-through: unlike an account or an order, an operator has no
252/// ACME wire form to augment -- [`AdminUser::to_json`] is already the only
253/// representation, and it is the one that omits the password hash.
254#[must_use]
255pub fn render_admin_user_json(user: &AdminUser) -> Value {
256    user.to_json()
257}
258
259/// `admin session list --json`.
260#[must_use]
261pub fn render_admin_session_json(session: &AdminSession) -> Value {
262    session.to_json()
263}
264
265#[cfg(test)]
266mod tests {
267    use std::sync::Arc;
268
269    use super::*;
270    use crate::admin::ops::load_order_detail;
271    use crate::audit::ClientContext;
272    use crate::sqlite::authz::{Authorization, Challenge};
273    use crate::sqlite::db::Database;
274    use crate::sqlite::order::Identifier;
275    use crate::sqlite::status::OrderStatus;
276    use crate::testutil::{
277        account_id, account_seen_from, admin_session_fixture, admin_user_fixture, client_context,
278        order_fixture,
279    };
280
281    #[tokio::test]
282    async fn render_account_json_includes_id_and_base_fields() {
283        let db = Arc::new(Database::connect_in_memory().await.unwrap());
284        let account = account_seen_from(
285            &[1u8, 2, 3],
286            &client_context(Some("203.0.113.7"), Some("host.example.com")),
287            &db,
288        )
289        .await;
290
291        let json = render_account_json(&account, "http://localhost:3000");
292        assert_eq!(json["id"], account.id);
293        assert_eq!(json["status"], "valid");
294        assert!(json["orders"].as_str().unwrap().contains(&account.id));
295        assert!(json["pubkeyFingerprint"].is_string());
296        assert_eq!(json["createdIp"], "203.0.113.7");
297        assert_eq!(json["createdPtr"], "host.example.com");
298        assert_eq!(json["lastSeenIp"], "203.0.113.7");
299        assert_eq!(json["lastSeenPtr"], "host.example.com");
300        assert!(json["lastSeenAt"].as_str().unwrap().contains('T'));
301    }
302
303    /// Absent, not `null`: a template asking `{% if account.createdIp %}` must
304    /// be asking the question it looks like it is asking.
305    #[tokio::test]
306    async fn render_account_json_omits_the_traceability_members_it_has_no_value_for() {
307        let db = Arc::new(Database::connect_in_memory().await.unwrap());
308        let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
309
310        let json = render_account_json(&account, "http://localhost:3000");
311        let object = json.as_object().unwrap();
312        for key in ["createdIp", "createdPtr", "lastSeenIp", "lastSeenPtr"] {
313            assert!(!object.contains_key(key), "{key} in {json}");
314        }
315    }
316
317    #[test]
318    fn render_order_json_includes_id_and_authorizations() {
319        let order = order_fixture("acct", OrderStatus::Pending);
320        let authz_ids = vec!["authz-1".to_string()];
321        let json = render_order_json(&order, "http://localhost:3000", &authz_ids);
322        assert_eq!(json["id"], order.id);
323        // Admin output is rendered against the *profile's* base URL, not the
324        // server's: that is the URL the client was handed.
325        assert_eq!(json["profile"], "default");
326        // Admin-only, and absent from the ACME order object: without it
327        // neither front end could link an order back to its account.
328        assert_eq!(json["accountId"], "acct");
329        assert_eq!(
330            json["authorizations"],
331            serde_json::json!(["http://localhost:3000/profile/default/authz/authz-1"])
332        );
333    }
334
335    #[tokio::test]
336    async fn render_order_detail_json_nests_authorizations_and_challenges() {
337        let db = Arc::new(Database::connect_in_memory().await.unwrap());
338        let acct = account_id(&db).await;
339        let order = Order::create(
340            "default",
341            &acct,
342            vec![Identifier::dns("example.com")],
343            crate::sqlite::nonce::now_secs() + 3600,
344            None,
345            None,
346            &db,
347        )
348        .await
349        .unwrap();
350        let authz = Authorization::create(
351            &order.id,
352            Identifier::dns("example.com"),
353            crate::sqlite::nonce::now_secs() + 3600,
354            &db,
355        )
356        .await
357        .unwrap();
358        Challenge::create(&authz.id, "http-01", &db).await.unwrap();
359
360        let detail = load_order_detail(&order.id, db).await.unwrap().unwrap();
361        let json = render_order_detail_json(&detail, "http://localhost:3000");
362        assert_eq!(json["order"]["id"], order.id);
363        assert_eq!(json["authorizations"].as_array().unwrap().len(), 1);
364        assert_eq!(
365            json["authorizations"][0]["challenges"]
366                .as_array()
367                .unwrap()
368                .len(),
369            1
370        );
371    }
372
373    #[tokio::test]
374    async fn render_eab_created_json_includes_the_hmac_key_and_line_json_does_not() {
375        let db = Arc::new(Database::connect_in_memory().await.unwrap());
376        let eab = Eab::create(None, None, &db).await.unwrap();
377
378        let created = render_eab_created_json(&eab);
379        assert!(created["hmacKey"].is_string());
380
381        let listed = render_eab_json(&eab);
382        assert!(listed.get("hmacKey").is_none());
383    }
384
385    #[test]
386    fn render_order_json_revoked_includes_reason_and_time() {
387        let mut order = order_fixture("acct", OrderStatus::Valid);
388        order.revoked_at = Some(1700000000);
389        order.revocation_reason = Some(1);
390        let json = render_order_json(&order, "http://localhost:3000", &[]);
391        assert_eq!(json["revokedAt"].as_str().unwrap().len(), 20);
392        assert_eq!(json["revocationReason"], 1);
393    }
394
395    #[test]
396    fn render_order_json_carries_the_cert_serial_and_omits_it_when_unissued() {
397        // The complaint this member answers: `audit list --cert-serial` and
398        // `GET /api/audit?certSerial=` both filter on this value, and until now
399        // no order rendering would tell an operator what it was.
400        let mut order = order_fixture("acct", OrderStatus::Valid);
401        order.cert_serial = Some("03a7f1c9".to_string());
402        let json = render_order_json(&order, "http://localhost:3000", &[]);
403        assert_eq!(json["certSerial"], "03a7f1c9");
404
405        // Omitted, not nulled: an order that never issued has no serial, which
406        // is a different statement from an empty one.
407        let unissued = order_fixture("acct", OrderStatus::Pending);
408        let json = render_order_json(&unissued, "http://localhost:3000", &[]);
409        assert!(json.get("certSerial").is_none());
410    }
411
412    #[test]
413    fn render_admin_user_json_omits_every_secret() {
414        let mut user = admin_user_fixture();
415        user.totp_secret = Some(vec![9, 9, 9]);
416        let json = render_admin_user_json(&user);
417        let rendered = json.to_string();
418        assert!(!rendered.contains("pbkdf2"));
419        assert!(!rendered.contains("totpSecret"));
420        assert_eq!(json["username"], "alice");
421        assert_eq!(json["totpEnabled"], true);
422    }
423
424    #[test]
425    fn render_admin_session_json_omits_the_hash_and_the_csrf_token() {
426        let json = render_admin_session_json(&admin_session_fixture());
427        let rendered = json.to_string();
428        assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
429        assert!(!rendered.contains("the-csrf-token"));
430        assert_eq!(json["id"], "01234567");
431        assert_eq!(json["state"], "active");
432    }
433}