use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Value, json};
use uuid::Uuid;
use crate::admin;
use crate::admin::ops::{RevokeError, RevokeOutcome};
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::Caller;
use crate::webadmin::handlers::paging::{PageParams, page_envelope};
use crate::webadmin::handlers::params::{bad_status, empty_is_absent, empty_is_absent_serial};
use crate::webadmin::session::{Authenticated, AuthenticatedWrite};
use acme_proxy_store::authz::Authorization;
use acme_proxy_store::order::Order;
use acme_proxy_store::order::OrderQuery;
use acme_proxy_store::status::OrderStatus;
use acme_proxy_store::status::UnknownStatus;
#[derive(Debug, Deserialize, Default)]
pub struct OrderListParams {
#[serde(default, deserialize_with = "empty_is_absent")]
pub profile: Option<String>,
#[serde(rename = "accountId", default, deserialize_with = "empty_is_absent")]
pub account_id: Option<String>,
#[serde(default, deserialize_with = "empty_is_absent")]
pub status: Option<String>,
#[serde(default, deserialize_with = "empty_is_absent")]
pub identifier: Option<String>,
#[serde(
rename = "identifierContains",
default,
deserialize_with = "empty_is_absent"
)]
pub identifier_contains: Option<String>,
#[serde(
rename = "certSerial",
default,
deserialize_with = "empty_is_absent_serial"
)]
pub cert_serial: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
impl OrderListParams {
pub fn parsed_status(&self) -> Result<Option<OrderStatus>, UnknownStatus> {
self.status.as_deref().map(str::parse).transpose()
}
pub fn check_identifier_filters(&self) -> Result<(), &'static str> {
if self.identifier.is_some() && self.identifier_contains.is_some() {
return Err("give either identifier or identifierContains, not both");
}
Ok(())
}
}
fn bad_identifier_filters(message: &'static str) -> AdminError {
AdminError::with_code(
StatusCode::BAD_REQUEST,
"conflicting_identifier_filter",
message,
)
}
#[derive(Debug, Deserialize, Default)]
pub struct RevokeRequest {
#[serde(default)]
pub reason: Option<u32>,
}
pub(crate) fn order_query(
params: OrderListParams,
page: crate::webadmin::handlers::paging::Page,
) -> Result<OrderQuery, AdminError> {
let status = params.parsed_status().map_err(bad_status)?;
params
.check_identifier_filters()
.map_err(bad_identifier_filters)?;
Ok(OrderQuery {
profile: params.profile,
account_id: params.account_id,
status,
identifier: params.identifier,
identifier_contains: params.identifier_contains,
cert_serial: params.cert_serial,
limit: page.limit,
offset: page.offset,
})
}
pub async fn list_orders(
State(state): State<AdminState>,
Query(params): Query<OrderListParams>,
_auth: Authenticated,
) -> Result<Json<Value>, AdminError> {
let page = PageParams::from(params.limit, params.offset).resolve(&state.config);
let query = order_query(params, page)?;
let (orders, total) = Order::search(&query, &state.database).await?;
let items = render_orders(&orders, &state).await?;
Ok(Json(page_envelope(items, total, page)))
}
pub async fn get_order(
State(state): State<AdminState>,
Path(id): Path<String>,
_auth: Authenticated,
) -> Result<Json<Value>, AdminError> {
let detail = admin::load_order_detail(&id, state.database.clone())
.await?
.ok_or_else(|| not_found(&id))?;
Ok(Json(admin::render_order_detail_json(
&detail,
&state.config.server.base_url,
)))
}
pub async fn revoke_order(
State(state): State<AdminState>,
Path(id): Path<String>,
request_context: acme_proxy_core::audit::RequestContext,
AuthenticatedWrite(auth): AuthenticatedWrite,
body: Option<Json<RevokeRequest>>,
) -> Result<Response, AdminError> {
let reason = body.and_then(|Json(body)| body.reason);
match apply_revoke_order(&state, &Caller::api(&auth, &request_context), &id, reason).await? {
Revoked::Now(order) => {
let authz_ids = authz_ids(order.id, &state).await?;
Ok(Json(admin::render_order_json(
&order,
&state.config.server.base_url,
&authz_ids,
))
.into_response())
}
Revoked::Queued(job) => Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({ "status": "queued", "job": job.to_string() })),
)
.into_response()),
}
}
pub(crate) enum Revoked {
Now(Box<Order>),
Queued(Uuid),
}
pub(crate) async fn apply_revoke_order(
state: &AdminState,
caller: &Caller<'_>,
id: &str,
reason: Option<u32>,
) -> Result<Revoked, AdminError> {
let profile = resolve_order_profile(state, id).await?;
let route = profile.signer_info.revocation_route();
let outcome = admin::revoke_order(
id,
reason,
acme_proxy_core::audit::Actor::admin(caller.username()),
state.audit.client(caller.request).await,
acme_proxy_protocol::acme::revoke::Revocations {
database: &state.database,
audit: &state.audit,
notify: Some(&profile.notify),
revoker: revoker(state, &route),
},
)
.await
.map_err(revoke_error)?;
match outcome {
RevokeOutcome::NotFound => Err(not_found(id)),
RevokeOutcome::NotIssued => Err(AdminError::conflict(
"order_not_issued",
format!("order {id} has no certificate to revoke"),
)),
RevokeOutcome::AlreadyRevoked => Err(AdminError::conflict(
"already_revoked",
format!("order {id} was already revoked"),
)),
RevokeOutcome::Revoked(order) => {
tracing::info!(event = "admin_order_revoked",
outcome = "success",
surface = caller.surface,
order_id = %id,
profile = %order.profile,
reason = ?reason,
username = %caller.username());
Ok(Revoked::Now(order))
}
RevokeOutcome::Queued(job) => {
tracing::info!(event = "admin_order_revoke_queued",
outcome = "progress",
surface = caller.surface,
order_id = %id,
job_id = %job,
username = %caller.username());
Ok(Revoked::Queued(job))
}
}
}
pub(crate) fn revoker<'a>(
state: &'a AdminState,
route: &'a acme_proxy_signer::RevocationRoute,
) -> acme_proxy_protocol::acme::revoke::Revoker<'a> {
acme_proxy_protocol::acme::revoke::Revoker::for_route(
route,
&state.jobs,
acme_proxy_protocol::acme::revoke::request_wait(state.config.server.request_timeout_ms),
)
}
pub async fn delete_order(
State(state): State<AdminState>,
Path(id): Path<String>,
AuthenticatedWrite(auth): AuthenticatedWrite,
request_context: acme_proxy_core::audit::RequestContext,
) -> Result<Response, AdminError> {
let cascaded = apply_delete_order(&state, &Caller::api(&auth, &request_context), &id).await?;
Ok((
StatusCode::OK,
Json(json!({ "deleted": { "authorizations": cascaded } })),
)
.into_response())
}
pub(crate) async fn apply_delete_order(
state: &AdminState,
caller: &Caller<'_>,
id: &str,
) -> Result<u64, AdminError> {
let subject = Order::find_by_id(id, &state.database).await?;
let deleted = match admin::delete_order(id, state.database.clone()).await? {
admin::Deletion::NotFound => return Err(not_found(id)),
admin::Deletion::LiveCertificates(live) => {
return Err(AdminError::conflict(
"live_certificates",
admin::live_certificates_refusal(&format!("order {id}"), live),
));
}
admin::Deletion::Deleted(deleted) => deleted,
};
if let Some(order) = subject {
state
.record_admin_action(caller.request, caller.username(), |actor, client| {
acme_proxy_jobs::auditor::admin::order_deleted(
actor,
client,
&order,
deleted.cascaded,
)
})
.await;
}
tracing::info!(event = "admin_order_deleted",
outcome = "success",
surface = caller.surface,
order_id = %id,
username = %caller.username(),
cascaded_authorizations = deleted.cascaded);
Ok(deleted.cascaded)
}
pub(crate) async fn render_orders(
orders: &[Order],
state: &AdminState,
) -> Result<Vec<Value>, AdminError> {
Ok(admin::orders_json(orders, &state.config.server.base_url, &state.database).await?)
}
async fn authz_ids(order_id: Uuid, state: &AdminState) -> Result<Vec<Uuid>, AdminError> {
Ok(Authorization::find_by_order(order_id, &state.database)
.await?
.into_iter()
.map(|authz| authz.id)
.collect())
}
pub(crate) fn revoke_error(error: RevokeError) -> AdminError {
match error {
RevokeError::BadReason(reason) => AdminError::bad_request(format!(
"unsupported revocation reason code {reason} (RFC 5280 §5.3.1)"
)),
RevokeError::Signer(_) | RevokeError::Abandoned { .. } => {
tracing::error!(event = "admin_revoke_signer_failed", outcome = "failure", error = %error);
AdminError::signer_failed("the signer backend refused the revocation; retry")
}
RevokeError::Database(inner) => AdminError::from(inner),
RevokeError::Internal(detail) => {
tracing::error!(event = "admin_revoke_internal_error", outcome = "failure", error = %detail);
AdminError::internal()
}
}
}
pub(crate) async fn resolve_order_profile(
state: &AdminState,
id: &str,
) -> Result<std::sync::Arc<acme_proxy_protocol::profile::Profile>, AdminError> {
let order = Order::find_by_id(id, &state.database)
.await?
.ok_or_else(|| not_found(id))?;
let profile = state.profiles.get(&order.profile).ok_or_else(|| {
AdminError::conflict(
"profile_not_mounted",
format!(
"order {id} belongs to profile `{}`, which this configuration does not mount",
order.profile
),
)
})?;
Ok(profile.clone())
}
fn not_found(id: &str) -> AdminError {
AdminError::not_found(crate::admin::subject::Subject::Order.missing(id))
}