1use base64::prelude::*;
18use serde_json::Value;
19use uuid::Uuid;
20
21use crate::admin::ops::{JobDetail, OrderDetail, UpstreamOrderDetail};
22use acme_proxy_core::datetime::rfc3339;
23use acme_proxy_store::account::Account;
24use acme_proxy_store::account::pubkey_fingerprint;
25use acme_proxy_store::admin_session::AdminSession;
26use acme_proxy_store::admin_user::AdminUser;
27use acme_proxy_store::eab::Eab;
28use acme_proxy_store::expiring::ExpiringEntry;
29use acme_proxy_store::job::Job;
30use acme_proxy_store::order::Order;
31use acme_proxy_store::upstream_order::UpstreamOrderRow;
32
33use acme_proxy_core::routes::profile_base_url;
34
35#[must_use]
42pub fn render_account_json(account: &Account, base_url: &str) -> Value {
43 let mut object = account
44 .to_json(&profile_base_url(base_url, &account.profile))
45 .as_object()
46 .cloned()
47 .unwrap_or_default();
48 object.insert("id".to_string(), Value::String(account.id.to_string()));
49 object.insert(
50 "profile".to_string(),
51 Value::String(account.profile.clone()),
52 );
53 object.insert(
54 "createdAt".to_string(),
55 Value::String(rfc3339(account.created_at)),
56 );
57 object.insert(
58 "pubkeyFingerprint".to_string(),
59 Value::String(pubkey_fingerprint(&account.pubkey)),
60 );
61 if let Some(kid) = account.eab_kid {
64 object.insert("eabKid".to_string(), Value::String(kid.to_string()));
65 }
66 if let Some(seen) = account.last_seen_at {
67 object.insert("lastSeenAt".to_string(), Value::String(rfc3339(seen)));
68 }
69 for (key, value) in [
70 ("createdIp", account.created_ip.as_ref()),
71 ("createdPtr", account.created_ptr.as_ref()),
72 ("lastSeenIp", account.last_seen_ip.as_ref()),
73 ("lastSeenPtr", account.last_seen_ptr.as_ref()),
74 ] {
75 if let Some(value) = value {
76 object.insert(key.to_string(), Value::String(value.clone()));
77 }
78 }
79 Value::Object(object)
80}
81
82#[must_use]
84pub fn render_order_json(order: &Order, base_url: &str, authz_ids: &[Uuid]) -> Value {
85 let mut object = order
86 .to_json(&profile_base_url(base_url, &order.profile), authz_ids)
87 .as_object()
88 .cloned()
89 .unwrap_or_default();
90 object.insert("id".to_string(), Value::String(order.id.to_string()));
91 object.insert("profile".to_string(), Value::String(order.profile.clone()));
92 object.insert(
97 "accountId".to_string(),
98 Value::String(order.account_id.to_string()),
99 );
100 object.insert(
101 "createdAt".to_string(),
102 Value::String(rfc3339(order.created_at)),
103 );
104 if let Some(serial) = order.cert_serial.as_ref() {
113 object.insert("certSerial".to_string(), Value::String(serial.clone()));
114 }
115 if let Some(not_after) = order.cert_not_after.filter(|value| *value >= 0) {
123 object.insert(
124 "certNotAfter".to_string(),
125 Value::String(rfc3339(not_after)),
126 );
127 }
128 if let Some(revoked_at) = order.revoked_at {
129 object.insert("revokedAt".to_string(), Value::String(rfc3339(revoked_at)));
130 if let Some(reason) = order.revocation_reason {
131 object.insert("revocationReason".to_string(), Value::from(reason));
132 }
133 }
134 Value::Object(object)
135}
136
137#[must_use]
139pub fn render_order_detail_json(detail: &OrderDetail, base_url: &str) -> Value {
140 let authz_ids: Vec<Uuid> = detail.authorizations.iter().map(|(a, _)| a.id).collect();
141 let profile_base = profile_base_url(base_url, &detail.order.profile);
142 let authorizations: Vec<Value> = detail
143 .authorizations
144 .iter()
145 .map(|(a, c)| a.to_json(&profile_base, c))
146 .collect();
147 let mut order = render_order_json(&detail.order, base_url, &authz_ids);
148 if let (Some(object), Some(pem)) = (order.as_object_mut(), detail.order.certificate.as_ref()) {
159 object.insert("certificatePem".to_string(), Value::String(pem.clone()));
160 }
161
162 let mut root = serde_json::Map::new();
163 root.insert("order".to_string(), order);
164 root.insert("authorizations".to_string(), Value::Array(authorizations));
165 Value::Object(root)
166}
167
168#[must_use]
176pub fn render_job_json(job: &Job) -> Value {
177 let mut object = serde_json::Map::new();
178 object.insert("id".to_string(), Value::String(job.id.to_string()));
179 object.insert("kind".to_string(), Value::String(job.kind.clone()));
180 object.insert("dedupKey".to_string(), Value::String(job.dedup_key.clone()));
181 object.insert("payload".to_string(), job.payload.clone());
182 object.insert("status".to_string(), Value::String(job.status.clone()));
183 object.insert("runAt".to_string(), Value::String(rfc3339(job.run_at)));
184 object.insert("attempts".to_string(), Value::from(job.attempts));
185 object.insert("maxAttempts".to_string(), Value::from(job.max_attempts));
186 if let Some(deadline) = job.deadline {
187 object.insert("deadline".to_string(), Value::String(rfc3339(deadline)));
188 }
189 if let Some(lease_until) = job.lease_until {
190 object.insert(
191 "leaseUntil".to_string(),
192 Value::String(rfc3339(lease_until)),
193 );
194 }
195 if let Some(owner) = job.lease_owner.as_ref() {
196 object.insert("leaseOwner".to_string(), Value::String(owner.clone()));
197 }
198 if let Some(error) = job.last_error.as_ref() {
199 object.insert("lastError".to_string(), Value::String(error.clone()));
200 }
201 object.insert(
202 "createdAt".to_string(),
203 Value::String(rfc3339(job.created_at)),
204 );
205 object.insert(
206 "updatedAt".to_string(),
207 Value::String(rfc3339(job.updated_at)),
208 );
209 Value::Object(object)
210}
211
212#[must_use]
219pub fn render_upstream_order_json(row: &UpstreamOrderRow) -> Value {
220 let mut object = serde_json::Map::new();
221 object.insert(
222 "orderId".to_string(),
223 Value::String(row.order_id.to_string()),
224 );
225 object.insert("profile".to_string(), Value::String(row.profile.clone()));
226 object.insert(
227 "accountId".to_string(),
228 Value::String(row.account_id.to_string()),
229 );
230 object.insert("status".to_string(), Value::String(row.status.clone()));
231 object.insert(
232 "localStatus".to_string(),
233 Value::String(row.local_status.as_str().to_string()),
234 );
235 object.insert(
236 "localExpires".to_string(),
237 Value::String(rfc3339(row.local_expires)),
238 );
239 object.insert(
240 "identifiers".to_string(),
241 serde_json::to_value(&row.identifiers).unwrap_or(Value::Null),
242 );
243 object.insert(
244 "upstreamOrderUrl".to_string(),
245 Value::String(row.upstream_order_url.clone()),
246 );
247 if let Some(url) = row.upstream_finalize_url.as_ref() {
248 object.insert(
249 "upstreamFinalizeUrl".to_string(),
250 Value::String(url.clone()),
251 );
252 }
253 if let Some(url) = row.upstream_certificate_url.as_ref() {
254 object.insert(
255 "upstreamCertificateUrl".to_string(),
256 Value::String(url.clone()),
257 );
258 }
259 if let Some(error) = row.error.as_ref() {
260 object.insert("error".to_string(), Value::String(error.clone()));
261 }
262 if let Some(ip) = row.client_ip.as_ref() {
263 object.insert("clientIp".to_string(), Value::String(ip.clone()));
264 }
265 if let Some(ptr) = row.client_ptr.as_ref() {
266 object.insert("clientPtr".to_string(), Value::String(ptr.clone()));
267 }
268 if let Some(ua) = row.user_agent.as_ref() {
269 object.insert("userAgent".to_string(), Value::String(ua.clone()));
270 }
271 if let Some(request_id) = row.request_id.as_ref() {
272 object.insert("requestId".to_string(), Value::String(request_id.clone()));
273 }
274 object.insert(
275 "createdAt".to_string(),
276 Value::String(rfc3339(row.created_at)),
277 );
278 object.insert(
279 "updatedAt".to_string(),
280 Value::String(rfc3339(row.updated_at)),
281 );
282 Value::Object(object)
283}
284
285#[must_use]
288pub fn render_job_detail_json(detail: &JobDetail) -> Value {
289 let mut object = render_job_json(&detail.job)
290 .as_object()
291 .cloned()
292 .unwrap_or_default();
293 if let Some(upstream) = detail.upstream_order.as_ref() {
294 object.insert(
295 "upstreamOrder".to_string(),
296 render_upstream_order_json(upstream),
297 );
298 }
299 Value::Object(object)
300}
301
302#[must_use]
305pub fn render_upstream_order_detail_json(detail: &UpstreamOrderDetail) -> Value {
306 let mut object = render_upstream_order_json(&detail.upstream_order)
307 .as_object()
308 .cloned()
309 .unwrap_or_default();
310 if let Some(job) = detail.job.as_ref() {
311 object.insert("job".to_string(), render_job_json(job));
312 }
313 Value::Object(object)
314}
315
316#[must_use]
330pub fn render_expiring_json(entry: &ExpiringEntry) -> Value {
331 let order = &entry.order;
332 let mut object = serde_json::Map::new();
333 object.insert("orderId".to_string(), Value::String(order.id.to_string()));
334 object.insert("profile".to_string(), Value::String(order.profile.clone()));
335 object.insert(
336 "accountId".to_string(),
337 Value::String(order.account_id.to_string()),
338 );
339 object.insert(
340 "certSerial".to_string(),
341 Value::String(order.cert_serial.clone().unwrap_or_default()),
342 );
343 object.insert(
344 "identifiers".to_string(),
345 Value::Array(
346 order
347 .identifiers
348 .iter()
349 .map(|identifier| Value::String(identifier.value.clone()))
350 .collect(),
351 ),
352 );
353 object.insert(
354 "notAfter".to_string(),
355 Value::String(rfc3339(order.cert_not_after.unwrap_or_default())),
356 );
357 object.insert(
358 "daysRemaining".to_string(),
359 Value::from(entry.days_remaining),
360 );
361 if let Some(superseded) = &entry.superseded_by {
362 object.insert(
363 "supersededBy".to_string(),
364 serde_json::json!({
365 "orderId": superseded.order_id,
366 "certSerial": superseded.cert_serial,
367 "notAfter": rfc3339(superseded.not_after),
368 "via": superseded.via,
369 }),
370 );
371 }
372 Value::Object(object)
373}
374
375#[must_use]
377pub fn render_eab_json(eab: &Eab) -> Value {
378 eab.to_json()
379}
380
381#[must_use]
383pub fn render_eab_created_json(eab: &Eab) -> Value {
384 let mut object = eab.to_json().as_object().cloned().unwrap_or_default();
385 object.insert(
386 "hmacKey".to_string(),
387 Value::String(BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)),
388 );
389 Value::Object(object)
390}
391
392#[must_use]
398pub fn render_admin_user_json(user: &AdminUser) -> Value {
399 user.to_json()
400}
401
402#[must_use]
412pub fn render_admin_user_detail_json(user: &AdminUser, recovery_codes_remaining: i64) -> Value {
413 let mut object = render_admin_user_json(user)
414 .as_object()
415 .cloned()
416 .unwrap_or_default();
417 object.insert(
418 "enrolmentPending".to_string(),
419 Value::Bool(user.has_pending_totp()),
420 );
421 object.insert(
422 "recoveryCodesRemaining".to_string(),
423 Value::from(recovery_codes_remaining),
424 );
425 Value::Object(object)
426}
427
428#[must_use]
430pub fn render_admin_session_json(session: &AdminSession) -> Value {
431 session.to_json()
432}
433
434#[must_use]
444pub fn render_admin_session_detail_json(session: &AdminSession, current_token_hash: &str) -> Value {
445 let mut object = render_admin_session_json(session)
446 .as_object()
447 .cloned()
448 .unwrap_or_default();
449 object.insert(
450 "current".to_string(),
451 Value::Bool(session.token_hash == current_token_hash),
452 );
453 Value::Object(object)
454}
455
456#[must_use]
464pub fn render_nonce_stats_json(count: i64, ttl_seconds: u64) -> Value {
465 serde_json::json!({
466 "count": count,
467 "ttlSeconds": ttl_seconds,
468 })
469}
470
471pub struct ProfileSummary {
484 pub name: String,
485 pub base_url: String,
486 pub challenge_bypass: bool,
487 pub eab_enabled: bool,
488}
489
490impl ProfileSummary {
491 #[must_use]
493 pub fn mounted(profile: &acme_proxy_protocol::profile::Profile) -> Self {
494 Self {
495 name: profile.name.clone(),
496 base_url: profile.base_url.clone(),
497 challenge_bypass: profile.challenges.is_bypassed(),
498 eab_enabled: profile.eab.enabled,
499 }
500 }
501
502 #[must_use]
509 pub fn configured(base_url: &str, profile: &acme_proxy_core::config::ProfileConfig) -> Self {
510 Self {
511 name: profile.name.clone(),
512 base_url: profile_base_url(base_url, &profile.name),
513 challenge_bypass: profile.sections.challenge.bypass,
514 eab_enabled: profile.sections.eab.enabled,
515 }
516 }
517
518 #[must_use]
520 pub fn directory_url(&self) -> String {
521 format!("{}{}", self.base_url, acme_proxy_core::routes::DIRECTORY)
522 }
523}
524
525#[must_use]
527pub fn render_profile_json(profile: &ProfileSummary) -> Value {
528 serde_json::json!({
529 "name": profile.name,
530 "baseUrl": profile.base_url,
531 "directory": profile.directory_url(),
532 "challengeBypass": profile.challenge_bypass,
533 "eabEnabled": profile.eab_enabled,
534 })
535}
536
537#[cfg(test)]
538mod tests {
539 use std::sync::Arc;
540
541 use super::*;
542 use crate::admin::ops::load_order_detail;
543 use acme_proxy_core::audit::ClientContext;
544 use acme_proxy_core::identifier::Identifier;
545 use acme_proxy_store::authz::Authorization;
546 use acme_proxy_store::authz::Challenge;
547 use acme_proxy_store::db::Database;
548 use acme_proxy_store::status::OrderStatus;
549 use acme_proxy_store::testutil::account_id;
550 use acme_proxy_store::testutil::account_seen_from;
551 use acme_proxy_store::testutil::admin_session_fixture;
552 use acme_proxy_store::testutil::admin_user_fixture;
553 use acme_proxy_store::testutil::client_context;
554 use acme_proxy_store::testutil::job_fixture;
555 use acme_proxy_store::testutil::order_fixture;
556 use acme_proxy_store::testutil::upstream_order_row_fixture;
557
558 #[tokio::test]
559 async fn render_account_json_includes_id_and_base_fields() {
560 let db = Arc::new(Database::connect_in_memory().await.unwrap());
561 let account = account_seen_from(
562 &[1u8, 2, 3],
563 &client_context(Some("203.0.113.7"), Some("host.example.com")),
564 &db,
565 )
566 .await;
567
568 let json = render_account_json(&account, "http://localhost:3000");
569 assert_eq!(json["id"], account.id.to_string());
570 assert_eq!(json["status"], "valid");
571 assert!(
572 json["orders"]
573 .as_str()
574 .unwrap()
575 .contains(&account.id.to_string())
576 );
577 assert!(json["pubkeyFingerprint"].is_string());
578 assert_eq!(json["createdIp"], "203.0.113.7");
579 assert_eq!(json["createdPtr"], "host.example.com");
580 assert_eq!(json["lastSeenIp"], "203.0.113.7");
581 assert_eq!(json["lastSeenPtr"], "host.example.com");
582 assert!(json["lastSeenAt"].as_str().unwrap().contains('T'));
583 }
584
585 #[tokio::test]
588 async fn render_account_json_omits_the_traceability_members_it_has_no_value_for() {
589 let db = Arc::new(Database::connect_in_memory().await.unwrap());
590 let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
591
592 let json = render_account_json(&account, "http://localhost:3000");
593 let object = json.as_object().unwrap();
594 for key in ["createdIp", "createdPtr", "lastSeenIp", "lastSeenPtr"] {
595 assert!(!object.contains_key(key), "{key} in {json}");
596 }
597 }
598
599 #[test]
600 fn render_order_json_includes_id_and_authorizations() {
601 let account = acme_proxy_store::id::mint();
602 let authz = acme_proxy_store::id::mint();
603 let order = order_fixture(account, OrderStatus::Pending);
604 let json = render_order_json(&order, "http://localhost:3000", &[authz]);
605 assert_eq!(json["id"], order.id.to_string());
606 assert_eq!(json["profile"], "default");
609 assert_eq!(json["accountId"], account.to_string());
612 assert_eq!(
613 json["authorizations"],
614 serde_json::json!([format!(
615 "http://localhost:3000/profile/default/authz/{authz}"
616 )])
617 );
618 }
619
620 #[tokio::test]
621 async fn render_order_detail_json_nests_authorizations_and_challenges() {
622 let db = Arc::new(Database::connect_in_memory().await.unwrap());
623 let acct = account_id(&db).await;
624 let order = Order::create(
625 "default",
626 acct,
627 vec![Identifier::dns("example.com")],
628 acme_proxy_store::nonce::now_secs() + 3600,
629 None,
630 None,
631 &db,
632 )
633 .await
634 .unwrap();
635 let authz = Authorization::create(
636 order.id,
637 Identifier::dns("example.com"),
638 acme_proxy_store::nonce::now_secs() + 3600,
639 &db,
640 )
641 .await
642 .unwrap();
643 Challenge::create(authz.id, "http-01", &db).await.unwrap();
644
645 let detail = load_order_detail(order.id.to_string().as_str(), db)
646 .await
647 .unwrap()
648 .unwrap();
649 let json = render_order_detail_json(&detail, "http://localhost:3000");
650 assert_eq!(json["order"]["id"], order.id.to_string());
651 assert_eq!(json["authorizations"].as_array().unwrap().len(), 1);
652 assert_eq!(
653 json["authorizations"][0]["challenges"]
654 .as_array()
655 .unwrap()
656 .len(),
657 1
658 );
659 }
660
661 #[tokio::test]
662 async fn render_eab_created_json_includes_the_hmac_key_and_line_json_does_not() {
663 let db = Arc::new(Database::connect_in_memory().await.unwrap());
664 let eab = Eab::create(None, None, &db).await.unwrap();
665
666 let created = render_eab_created_json(&eab);
667 assert!(created["hmacKey"].is_string());
668
669 let listed = render_eab_json(&eab);
670 assert!(listed.get("hmacKey").is_none());
671 }
672
673 #[test]
674 fn render_order_json_revoked_includes_reason_and_time() {
675 let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
676 order.revoked_at = Some(1700000000);
677 order.revocation_reason = Some(1);
678 let json = render_order_json(&order, "http://localhost:3000", &[]);
679 assert_eq!(json["revokedAt"].as_str().unwrap().len(), 20);
680 assert_eq!(json["revocationReason"], 1);
681 }
682
683 #[test]
684 fn render_order_json_carries_the_cert_serial_and_omits_it_when_unissued() {
685 let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
689 order.cert_serial = Some("03a7f1c9".to_string());
690 let json = render_order_json(&order, "http://localhost:3000", &[]);
691 assert_eq!(json["certSerial"], "03a7f1c9");
692
693 let unissued = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Pending);
696 let json = render_order_json(&unissued, "http://localhost:3000", &[]);
697 assert!(json.get("certSerial").is_none());
698 }
699
700 #[test]
701 fn render_admin_user_json_omits_every_secret() {
702 let mut user = admin_user_fixture();
703 user.totp_secret = Some(vec![9, 9, 9]);
704 let json = render_admin_user_json(&user);
705 let rendered = json.to_string();
706 assert!(!rendered.contains("pbkdf2"));
707 assert!(!rendered.contains("totpSecret"));
708 assert_eq!(json["username"], "alice");
709 assert_eq!(json["totpEnabled"], true);
710 }
711
712 #[test]
713 fn render_admin_session_json_omits_the_hash_and_the_csrf_token() {
714 let json = render_admin_session_json(&admin_session_fixture());
715 let rendered = json.to_string();
716 assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
717 assert!(!rendered.contains("the-csrf-token"));
718 assert_eq!(json["id"], "01234567");
719 assert_eq!(json["state"], "active");
720 }
721
722 #[test]
723 fn render_admin_session_detail_json_marks_only_the_matching_hash() {
724 let session = admin_session_fixture();
725 let mine = render_admin_session_detail_json(&session, &session.token_hash);
726 assert_eq!(mine["current"], true);
727 assert_eq!(mine["id"], "01234567");
729
730 let someone_elses = render_admin_session_detail_json(&session, "a-different-hash");
731 assert_eq!(someone_elses["current"], false);
732 }
733
734 #[test]
737 fn render_job_json_carries_the_payload_and_omits_absent_optionals() {
738 let mut job = job_fixture();
739 job.deadline = None;
740 job.lease_until = None;
741 job.lease_owner = None;
742 job.last_error = None;
743
744 let json = render_job_json(&job);
745 assert_eq!(json["kind"], "signer_relay_issue");
746 assert_eq!(json["dedupKey"], "order-1");
747 assert_eq!(json["payload"]["order_id"], "order-1");
748 assert_eq!(json["attempts"], 3);
749 assert_eq!(json["maxAttempts"], 5);
750 let object = json.as_object().unwrap();
751 for absent in ["deadline", "leaseUntil", "leaseOwner", "lastError"] {
752 assert!(!object.contains_key(absent), "{absent} should be omitted");
753 }
754 }
755
756 #[test]
757 fn render_upstream_order_json_never_carries_the_csr_and_omits_absent_optionals() {
758 let mut row = upstream_order_row_fixture();
759 row.upstream_finalize_url = None;
760 row.upstream_certificate_url = None;
761 row.error = None;
762 row.user_agent = None;
763
764 let json = render_upstream_order_json(&row);
765 let rendered = json.to_string();
766 assert!(!rendered.contains("csr"), "no csrDer, ever: {rendered}");
767 assert!(!rendered.contains("csrDer"));
768 assert_eq!(json["orderId"], row.order_id.to_string());
769 assert_eq!(json["localStatus"], "processing");
770 assert_eq!(json["identifiers"][0]["value"], "a.example.com");
771 let object = json.as_object().unwrap();
772 for absent in [
773 "upstreamFinalizeUrl",
774 "upstreamCertificateUrl",
775 "error",
776 "userAgent",
777 ] {
778 assert!(!object.contains_key(absent), "{absent} should be omitted");
779 }
780 }
781
782 #[test]
783 fn render_job_detail_json_attaches_the_upstream_cross_link() {
784 let detail = JobDetail {
785 job: job_fixture(),
786 upstream_order: Some(upstream_order_row_fixture()),
787 };
788 let json = render_job_detail_json(&detail);
789 assert_eq!(json["kind"], "signer_relay_issue");
790 assert_eq!(json["upstreamOrder"]["status"], "invalid");
791
792 let bare = JobDetail {
793 job: job_fixture(),
794 upstream_order: None,
795 };
796 assert!(
797 !render_job_detail_json(&bare)
798 .as_object()
799 .unwrap()
800 .contains_key("upstreamOrder")
801 );
802 }
803
804 #[test]
805 fn render_upstream_order_detail_json_attaches_the_job() {
806 let detail = UpstreamOrderDetail {
807 upstream_order: upstream_order_row_fixture(),
808 job: Some(job_fixture()),
809 };
810 let json = render_upstream_order_detail_json(&detail);
811 assert_eq!(json["status"], "invalid");
812 assert_eq!(json["job"]["kind"], "signer_relay_issue");
813 assert!(!json.to_string().contains("csrDer"));
814 }
815}