use base64::prelude::*;
use serde_json::Value;
use uuid::Uuid;
use crate::admin::ops::{JobDetail, OrderDetail, UpstreamOrderDetail};
use acme_proxy_core::datetime::rfc3339;
use acme_proxy_store::account::Account;
use acme_proxy_store::account::pubkey_fingerprint;
use acme_proxy_store::admin_session::AdminSession;
use acme_proxy_store::admin_user::AdminUser;
use acme_proxy_store::eab::Eab;
use acme_proxy_store::expiring::ExpiringEntry;
use acme_proxy_store::job::Job;
use acme_proxy_store::order::Order;
use acme_proxy_store::upstream_order::UpstreamOrderRow;
use acme_proxy_core::routes::profile_base_url;
#[must_use]
pub fn render_account_json(account: &Account, base_url: &str) -> Value {
let mut object = account
.to_json(&profile_base_url(base_url, &account.profile))
.as_object()
.cloned()
.unwrap_or_default();
object.insert("id".to_string(), Value::String(account.id.to_string()));
object.insert(
"profile".to_string(),
Value::String(account.profile.clone()),
);
object.insert(
"createdAt".to_string(),
Value::String(rfc3339(account.created_at)),
);
object.insert(
"pubkeyFingerprint".to_string(),
Value::String(pubkey_fingerprint(&account.pubkey)),
);
if let Some(kid) = account.eab_kid {
object.insert("eabKid".to_string(), Value::String(kid.to_string()));
}
if let Some(seen) = account.last_seen_at {
object.insert("lastSeenAt".to_string(), Value::String(rfc3339(seen)));
}
for (key, value) in [
("createdIp", account.created_ip.as_ref()),
("createdPtr", account.created_ptr.as_ref()),
("lastSeenIp", account.last_seen_ip.as_ref()),
("lastSeenPtr", account.last_seen_ptr.as_ref()),
] {
if let Some(value) = value {
object.insert(key.to_string(), Value::String(value.clone()));
}
}
Value::Object(object)
}
#[must_use]
pub fn render_order_json(order: &Order, base_url: &str, authz_ids: &[Uuid]) -> Value {
let mut object = order
.to_json(&profile_base_url(base_url, &order.profile), authz_ids)
.as_object()
.cloned()
.unwrap_or_default();
object.insert("id".to_string(), Value::String(order.id.to_string()));
object.insert("profile".to_string(), Value::String(order.profile.clone()));
object.insert(
"accountId".to_string(),
Value::String(order.account_id.to_string()),
);
object.insert(
"createdAt".to_string(),
Value::String(rfc3339(order.created_at)),
);
if let Some(serial) = order.cert_serial.as_ref() {
object.insert("certSerial".to_string(), Value::String(serial.clone()));
}
if let Some(not_after) = order.cert_not_after.filter(|value| *value >= 0) {
object.insert(
"certNotAfter".to_string(),
Value::String(rfc3339(not_after)),
);
}
if let Some(revoked_at) = order.revoked_at {
object.insert("revokedAt".to_string(), Value::String(rfc3339(revoked_at)));
if let Some(reason) = order.revocation_reason {
object.insert("revocationReason".to_string(), Value::from(reason));
}
}
Value::Object(object)
}
#[must_use]
pub fn render_order_detail_json(detail: &OrderDetail, base_url: &str) -> Value {
let authz_ids: Vec<Uuid> = detail.authorizations.iter().map(|(a, _)| a.id).collect();
let profile_base = profile_base_url(base_url, &detail.order.profile);
let authorizations: Vec<Value> = detail
.authorizations
.iter()
.map(|(a, c)| a.to_json(&profile_base, c))
.collect();
let mut order = render_order_json(&detail.order, base_url, &authz_ids);
if let (Some(object), Some(pem)) = (order.as_object_mut(), detail.order.certificate.as_ref()) {
object.insert("certificatePem".to_string(), Value::String(pem.clone()));
}
let mut root = serde_json::Map::new();
root.insert("order".to_string(), order);
root.insert("authorizations".to_string(), Value::Array(authorizations));
Value::Object(root)
}
#[must_use]
pub fn render_job_json(job: &Job) -> Value {
let mut object = serde_json::Map::new();
object.insert("id".to_string(), Value::String(job.id.to_string()));
object.insert("kind".to_string(), Value::String(job.kind.clone()));
object.insert("dedupKey".to_string(), Value::String(job.dedup_key.clone()));
object.insert("payload".to_string(), job.payload.clone());
object.insert("status".to_string(), Value::String(job.status.clone()));
object.insert("runAt".to_string(), Value::String(rfc3339(job.run_at)));
object.insert("attempts".to_string(), Value::from(job.attempts));
object.insert("maxAttempts".to_string(), Value::from(job.max_attempts));
if let Some(deadline) = job.deadline {
object.insert("deadline".to_string(), Value::String(rfc3339(deadline)));
}
if let Some(lease_until) = job.lease_until {
object.insert(
"leaseUntil".to_string(),
Value::String(rfc3339(lease_until)),
);
}
if let Some(owner) = job.lease_owner.as_ref() {
object.insert("leaseOwner".to_string(), Value::String(owner.clone()));
}
if let Some(error) = job.last_error.as_ref() {
object.insert("lastError".to_string(), Value::String(error.clone()));
}
object.insert(
"createdAt".to_string(),
Value::String(rfc3339(job.created_at)),
);
object.insert(
"updatedAt".to_string(),
Value::String(rfc3339(job.updated_at)),
);
Value::Object(object)
}
#[must_use]
pub fn render_upstream_order_json(row: &UpstreamOrderRow) -> Value {
let mut object = serde_json::Map::new();
object.insert(
"orderId".to_string(),
Value::String(row.order_id.to_string()),
);
object.insert("profile".to_string(), Value::String(row.profile.clone()));
object.insert(
"accountId".to_string(),
Value::String(row.account_id.to_string()),
);
object.insert("status".to_string(), Value::String(row.status.clone()));
object.insert(
"localStatus".to_string(),
Value::String(row.local_status.as_str().to_string()),
);
object.insert(
"localExpires".to_string(),
Value::String(rfc3339(row.local_expires)),
);
object.insert(
"identifiers".to_string(),
serde_json::to_value(&row.identifiers).unwrap_or(Value::Null),
);
object.insert(
"upstreamOrderUrl".to_string(),
Value::String(row.upstream_order_url.clone()),
);
if let Some(url) = row.upstream_finalize_url.as_ref() {
object.insert(
"upstreamFinalizeUrl".to_string(),
Value::String(url.clone()),
);
}
if let Some(url) = row.upstream_certificate_url.as_ref() {
object.insert(
"upstreamCertificateUrl".to_string(),
Value::String(url.clone()),
);
}
if let Some(error) = row.error.as_ref() {
object.insert("error".to_string(), Value::String(error.clone()));
}
if let Some(ip) = row.client_ip.as_ref() {
object.insert("clientIp".to_string(), Value::String(ip.clone()));
}
if let Some(ptr) = row.client_ptr.as_ref() {
object.insert("clientPtr".to_string(), Value::String(ptr.clone()));
}
if let Some(ua) = row.user_agent.as_ref() {
object.insert("userAgent".to_string(), Value::String(ua.clone()));
}
if let Some(request_id) = row.request_id.as_ref() {
object.insert("requestId".to_string(), Value::String(request_id.clone()));
}
object.insert(
"createdAt".to_string(),
Value::String(rfc3339(row.created_at)),
);
object.insert(
"updatedAt".to_string(),
Value::String(rfc3339(row.updated_at)),
);
Value::Object(object)
}
#[must_use]
pub fn render_job_detail_json(detail: &JobDetail) -> Value {
let mut object = render_job_json(&detail.job)
.as_object()
.cloned()
.unwrap_or_default();
if let Some(upstream) = detail.upstream_order.as_ref() {
object.insert(
"upstreamOrder".to_string(),
render_upstream_order_json(upstream),
);
}
Value::Object(object)
}
#[must_use]
pub fn render_upstream_order_detail_json(detail: &UpstreamOrderDetail) -> Value {
let mut object = render_upstream_order_json(&detail.upstream_order)
.as_object()
.cloned()
.unwrap_or_default();
if let Some(job) = detail.job.as_ref() {
object.insert("job".to_string(), render_job_json(job));
}
Value::Object(object)
}
#[must_use]
pub fn render_expiring_json(entry: &ExpiringEntry) -> Value {
let order = &entry.order;
let mut object = serde_json::Map::new();
object.insert("orderId".to_string(), Value::String(order.id.to_string()));
object.insert("profile".to_string(), Value::String(order.profile.clone()));
object.insert(
"accountId".to_string(),
Value::String(order.account_id.to_string()),
);
object.insert(
"certSerial".to_string(),
Value::String(order.cert_serial.clone().unwrap_or_default()),
);
object.insert(
"identifiers".to_string(),
Value::Array(
order
.identifiers
.iter()
.map(|identifier| Value::String(identifier.value.clone()))
.collect(),
),
);
object.insert(
"notAfter".to_string(),
Value::String(rfc3339(order.cert_not_after.unwrap_or_default())),
);
object.insert(
"daysRemaining".to_string(),
Value::from(entry.days_remaining),
);
if let Some(superseded) = &entry.superseded_by {
object.insert(
"supersededBy".to_string(),
serde_json::json!({
"orderId": superseded.order_id,
"certSerial": superseded.cert_serial,
"notAfter": rfc3339(superseded.not_after),
"via": superseded.via,
}),
);
}
Value::Object(object)
}
#[must_use]
pub fn render_eab_json(eab: &Eab) -> Value {
eab.to_json()
}
#[must_use]
pub fn render_eab_created_json(eab: &Eab) -> Value {
let mut object = eab.to_json().as_object().cloned().unwrap_or_default();
object.insert(
"hmacKey".to_string(),
Value::String(BASE64_URL_SAFE_NO_PAD.encode(&eab.secret)),
);
Value::Object(object)
}
#[must_use]
pub fn render_admin_user_json(user: &AdminUser) -> Value {
user.to_json()
}
#[must_use]
pub fn render_admin_user_detail_json(user: &AdminUser, recovery_codes_remaining: i64) -> Value {
let mut object = render_admin_user_json(user)
.as_object()
.cloned()
.unwrap_or_default();
object.insert(
"enrolmentPending".to_string(),
Value::Bool(user.has_pending_totp()),
);
object.insert(
"recoveryCodesRemaining".to_string(),
Value::from(recovery_codes_remaining),
);
Value::Object(object)
}
#[must_use]
pub fn render_admin_session_json(session: &AdminSession) -> Value {
session.to_json()
}
#[must_use]
pub fn render_admin_session_detail_json(session: &AdminSession, current_token_hash: &str) -> Value {
let mut object = render_admin_session_json(session)
.as_object()
.cloned()
.unwrap_or_default();
object.insert(
"current".to_string(),
Value::Bool(session.token_hash == current_token_hash),
);
Value::Object(object)
}
#[must_use]
pub fn render_nonce_stats_json(count: i64, ttl_seconds: u64) -> Value {
serde_json::json!({
"count": count,
"ttlSeconds": ttl_seconds,
})
}
pub struct ProfileSummary {
pub name: String,
pub base_url: String,
pub challenge_bypass: bool,
pub eab_enabled: bool,
}
impl ProfileSummary {
#[must_use]
pub fn mounted(profile: &acme_proxy_protocol::profile::Profile) -> Self {
Self {
name: profile.name.clone(),
base_url: profile.base_url.clone(),
challenge_bypass: profile.challenges.is_bypassed(),
eab_enabled: profile.eab.enabled,
}
}
#[must_use]
pub fn configured(base_url: &str, profile: &acme_proxy_core::config::ProfileConfig) -> Self {
Self {
name: profile.name.clone(),
base_url: profile_base_url(base_url, &profile.name),
challenge_bypass: profile.sections.challenge.bypass,
eab_enabled: profile.sections.eab.enabled,
}
}
#[must_use]
pub fn directory_url(&self) -> String {
format!("{}{}", self.base_url, acme_proxy_core::routes::DIRECTORY)
}
}
#[must_use]
pub fn render_profile_json(profile: &ProfileSummary) -> Value {
serde_json::json!({
"name": profile.name,
"baseUrl": profile.base_url,
"directory": profile.directory_url(),
"challengeBypass": profile.challenge_bypass,
"eabEnabled": profile.eab_enabled,
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::admin::ops::load_order_detail;
use acme_proxy_core::audit::ClientContext;
use acme_proxy_core::identifier::Identifier;
use acme_proxy_store::authz::Authorization;
use acme_proxy_store::authz::Challenge;
use acme_proxy_store::db::Database;
use acme_proxy_store::status::OrderStatus;
use acme_proxy_store::testutil::account_id;
use acme_proxy_store::testutil::account_seen_from;
use acme_proxy_store::testutil::admin_session_fixture;
use acme_proxy_store::testutil::admin_user_fixture;
use acme_proxy_store::testutil::client_context;
use acme_proxy_store::testutil::job_fixture;
use acme_proxy_store::testutil::order_fixture;
use acme_proxy_store::testutil::upstream_order_row_fixture;
#[tokio::test]
async fn render_account_json_includes_id_and_base_fields() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let account = account_seen_from(
&[1u8, 2, 3],
&client_context(Some("203.0.113.7"), Some("host.example.com")),
&db,
)
.await;
let json = render_account_json(&account, "http://localhost:3000");
assert_eq!(json["id"], account.id.to_string());
assert_eq!(json["status"], "valid");
assert!(
json["orders"]
.as_str()
.unwrap()
.contains(&account.id.to_string())
);
assert!(json["pubkeyFingerprint"].is_string());
assert_eq!(json["createdIp"], "203.0.113.7");
assert_eq!(json["createdPtr"], "host.example.com");
assert_eq!(json["lastSeenIp"], "203.0.113.7");
assert_eq!(json["lastSeenPtr"], "host.example.com");
assert!(json["lastSeenAt"].as_str().unwrap().contains('T'));
}
#[tokio::test]
async fn render_account_json_omits_the_traceability_members_it_has_no_value_for() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let account = account_seen_from(&[1u8, 2, 3], &ClientContext::default(), &db).await;
let json = render_account_json(&account, "http://localhost:3000");
let object = json.as_object().unwrap();
for key in ["createdIp", "createdPtr", "lastSeenIp", "lastSeenPtr"] {
assert!(!object.contains_key(key), "{key} in {json}");
}
}
#[test]
fn render_order_json_includes_id_and_authorizations() {
let account = acme_proxy_store::id::mint();
let authz = acme_proxy_store::id::mint();
let order = order_fixture(account, OrderStatus::Pending);
let json = render_order_json(&order, "http://localhost:3000", &[authz]);
assert_eq!(json["id"], order.id.to_string());
assert_eq!(json["profile"], "default");
assert_eq!(json["accountId"], account.to_string());
assert_eq!(
json["authorizations"],
serde_json::json!([format!(
"http://localhost:3000/profile/default/authz/{authz}"
)])
);
}
#[tokio::test]
async fn render_order_detail_json_nests_authorizations_and_challenges() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let acct = account_id(&db).await;
let order = Order::create(
"default",
acct,
vec![Identifier::dns("example.com")],
acme_proxy_store::nonce::now_secs() + 3600,
None,
None,
&db,
)
.await
.unwrap();
let authz = Authorization::create(
order.id,
Identifier::dns("example.com"),
acme_proxy_store::nonce::now_secs() + 3600,
&db,
)
.await
.unwrap();
Challenge::create(authz.id, "http-01", &db).await.unwrap();
let detail = load_order_detail(order.id.to_string().as_str(), db)
.await
.unwrap()
.unwrap();
let json = render_order_detail_json(&detail, "http://localhost:3000");
assert_eq!(json["order"]["id"], order.id.to_string());
assert_eq!(json["authorizations"].as_array().unwrap().len(), 1);
assert_eq!(
json["authorizations"][0]["challenges"]
.as_array()
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn render_eab_created_json_includes_the_hmac_key_and_line_json_does_not() {
let db = Arc::new(Database::connect_in_memory().await.unwrap());
let eab = Eab::create(None, None, &db).await.unwrap();
let created = render_eab_created_json(&eab);
assert!(created["hmacKey"].is_string());
let listed = render_eab_json(&eab);
assert!(listed.get("hmacKey").is_none());
}
#[test]
fn render_order_json_revoked_includes_reason_and_time() {
let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
order.revoked_at = Some(1700000000);
order.revocation_reason = Some(1);
let json = render_order_json(&order, "http://localhost:3000", &[]);
assert_eq!(json["revokedAt"].as_str().unwrap().len(), 20);
assert_eq!(json["revocationReason"], 1);
}
#[test]
fn render_order_json_carries_the_cert_serial_and_omits_it_when_unissued() {
let mut order = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Valid);
order.cert_serial = Some("03a7f1c9".to_string());
let json = render_order_json(&order, "http://localhost:3000", &[]);
assert_eq!(json["certSerial"], "03a7f1c9");
let unissued = order_fixture(acme_proxy_store::id::mint(), OrderStatus::Pending);
let json = render_order_json(&unissued, "http://localhost:3000", &[]);
assert!(json.get("certSerial").is_none());
}
#[test]
fn render_admin_user_json_omits_every_secret() {
let mut user = admin_user_fixture();
user.totp_secret = Some(vec![9, 9, 9]);
let json = render_admin_user_json(&user);
let rendered = json.to_string();
assert!(!rendered.contains("pbkdf2"));
assert!(!rendered.contains("totpSecret"));
assert_eq!(json["username"], "alice");
assert_eq!(json["totpEnabled"], true);
}
#[test]
fn render_admin_session_json_omits_the_hash_and_the_csrf_token() {
let json = render_admin_session_json(&admin_session_fixture());
let rendered = json.to_string();
assert!(!rendered.contains("0123456789abcdef0123456789abcdef"));
assert!(!rendered.contains("the-csrf-token"));
assert_eq!(json["id"], "01234567");
assert_eq!(json["state"], "active");
}
#[test]
fn render_admin_session_detail_json_marks_only_the_matching_hash() {
let session = admin_session_fixture();
let mine = render_admin_session_detail_json(&session, &session.token_hash);
assert_eq!(mine["current"], true);
assert_eq!(mine["id"], "01234567");
let someone_elses = render_admin_session_detail_json(&session, "a-different-hash");
assert_eq!(someone_elses["current"], false);
}
#[test]
fn render_job_json_carries_the_payload_and_omits_absent_optionals() {
let mut job = job_fixture();
job.deadline = None;
job.lease_until = None;
job.lease_owner = None;
job.last_error = None;
let json = render_job_json(&job);
assert_eq!(json["kind"], "signer_relay_issue");
assert_eq!(json["dedupKey"], "order-1");
assert_eq!(json["payload"]["order_id"], "order-1");
assert_eq!(json["attempts"], 3);
assert_eq!(json["maxAttempts"], 5);
let object = json.as_object().unwrap();
for absent in ["deadline", "leaseUntil", "leaseOwner", "lastError"] {
assert!(!object.contains_key(absent), "{absent} should be omitted");
}
}
#[test]
fn render_upstream_order_json_never_carries_the_csr_and_omits_absent_optionals() {
let mut row = upstream_order_row_fixture();
row.upstream_finalize_url = None;
row.upstream_certificate_url = None;
row.error = None;
row.user_agent = None;
let json = render_upstream_order_json(&row);
let rendered = json.to_string();
assert!(!rendered.contains("csr"), "no csrDer, ever: {rendered}");
assert!(!rendered.contains("csrDer"));
assert_eq!(json["orderId"], row.order_id.to_string());
assert_eq!(json["localStatus"], "processing");
assert_eq!(json["identifiers"][0]["value"], "a.example.com");
let object = json.as_object().unwrap();
for absent in [
"upstreamFinalizeUrl",
"upstreamCertificateUrl",
"error",
"userAgent",
] {
assert!(!object.contains_key(absent), "{absent} should be omitted");
}
}
#[test]
fn render_job_detail_json_attaches_the_upstream_cross_link() {
let detail = JobDetail {
job: job_fixture(),
upstream_order: Some(upstream_order_row_fixture()),
};
let json = render_job_detail_json(&detail);
assert_eq!(json["kind"], "signer_relay_issue");
assert_eq!(json["upstreamOrder"]["status"], "invalid");
let bare = JobDetail {
job: job_fixture(),
upstream_order: None,
};
assert!(
!render_job_detail_json(&bare)
.as_object()
.unwrap()
.contains_key("upstreamOrder")
);
}
#[test]
fn render_upstream_order_detail_json_attaches_the_job() {
let detail = UpstreamOrderDetail {
upstream_order: upstream_order_row_fixture(),
job: Some(job_fixture()),
};
let json = render_upstream_order_detail_json(&detail);
assert_eq!(json["status"], "invalid");
assert_eq!(json["job"]["kind"], "signer_relay_issue");
assert!(!json.to_string().contains("csrDer"));
}
}