Skip to main content

acme_proxy/webadmin/handlers/
orders.rs

1//! `/api/orders` — the orders across every mounted endpoint, and revocation.
2
3use axum::Json;
4use axum::extract::{Path, Query, State};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use serde::Deserialize;
8use serde_json::{Value, json};
9use uuid::Uuid;
10
11use crate::admin;
12use crate::admin::ops::{RevokeError, RevokeOutcome};
13use crate::sqlite::authz::Authorization;
14use crate::sqlite::order::{Order, OrderQuery};
15use crate::sqlite::status::{OrderStatus, UnknownStatus};
16use crate::webadmin::AdminState;
17use crate::webadmin::error::AdminError;
18use crate::webadmin::handlers::paging::{PageParams, page_envelope};
19use crate::webadmin::handlers::params::empty_is_absent;
20use crate::webadmin::session::{Authenticated, AuthenticatedWrite};
21
22/// The window fields are inline, not `#[serde(flatten)]` — see the note on
23/// [`super::accounts::AccountListParams`].
24#[derive(Debug, Deserialize, Default)]
25pub struct OrderListParams {
26    #[serde(default, deserialize_with = "empty_is_absent")]
27    pub profile: Option<String>,
28    #[serde(rename = "accountId", default, deserialize_with = "empty_is_absent")]
29    pub account_id: Option<String>,
30    #[serde(default, deserialize_with = "empty_is_absent")]
31    pub status: Option<String>,
32    pub limit: Option<i64>,
33    pub offset: Option<i64>,
34}
35
36impl OrderListParams {
37    /// The `status=` filter, parsed.
38    ///
39    /// Refused by name rather than passed to SQL: an unknown status matches no
40    /// rows, which a caller cannot tell from "nothing is in that state". Both
41    /// front ends call this, so `/api/orders?status=typo` and
42    /// `/ui/orders?status=typo` give the same answer.
43    pub fn parsed_status(&self) -> Result<Option<OrderStatus>, UnknownStatus> {
44        self.status.as_deref().map(str::parse).transpose()
45    }
46}
47
48#[derive(Debug, Deserialize, Default)]
49pub struct RevokeRequest {
50    /// RFC 5280 §5.3.1 reason code. Absent means "no reason recorded".
51    #[serde(default)]
52    pub reason: Option<u32>,
53}
54
55/// Turns an unparseable `status=` into a `400`, for either front end.
56fn bad_status(error: UnknownStatus) -> AdminError {
57    AdminError::with_code(StatusCode::BAD_REQUEST, "invalid_status", error.to_string())
58}
59
60/// `GET /api/orders?profile=&accountId=&status=&limit=&offset=`
61pub async fn list_orders(
62    State(state): State<AdminState>,
63    Query(params): Query<OrderListParams>,
64    _auth: Authenticated,
65) -> Result<Json<Value>, AdminError> {
66    let page = PageParams::from(params.limit, params.offset).resolve(&state.config);
67    // Parsed before the move, since the helper borrows `params`.
68    let status = params.parsed_status().map_err(bad_status)?;
69    let query = OrderQuery {
70        profile: params.profile,
71        account_id: params.account_id,
72        status,
73        limit: page.limit,
74        offset: page.offset,
75    };
76    let (orders, total) = Order::search(&query, &state.database).await?;
77
78    let items = render_orders(&orders, &state).await?;
79    Ok(Json(page_envelope(items, total, page)))
80}
81
82/// `GET /api/orders/{id}` — the order plus its authorizations and challenges.
83pub async fn get_order(
84    State(state): State<AdminState>,
85    Path(id): Path<String>,
86    _auth: Authenticated,
87) -> Result<Json<Value>, AdminError> {
88    let detail = admin::load_order_detail(&id, state.database.clone())
89        .await?
90        .ok_or_else(|| not_found(&id))?;
91    Ok(Json(admin::render_order_detail_json(
92        &detail,
93        &state.config.server.base_url,
94    )))
95}
96
97/// `POST /api/orders/{id}/revoke`
98///
99/// The signer comes from **the order's own profile**, not from any ambient
100/// default: two profiles can hold two different CAs, and revoking against the
101/// wrong one would record nothing useful and leave the real CRL untouched.
102///
103/// Better than the CLI's equivalent, which has to rebuild a backend from
104/// configuration: here the live, deduplicated instance is already in hand, so
105/// a `local_ca` CRL is regenerated by the very object that serves `GET /crl`.
106pub async fn revoke_order(
107    State(state): State<AdminState>,
108    Path(id): Path<String>,
109    request_context: crate::audit::RequestContext,
110    AuthenticatedWrite(auth): AuthenticatedWrite,
111    body: Option<Json<RevokeRequest>>,
112) -> Result<Json<Value>, AdminError> {
113    let reason = body.and_then(|Json(body)| body.reason);
114
115    // Resolve the profile before doing anything: an order belonging to a
116    // profile this process no longer mounts cannot be revoked here, and saying
117    // so plainly beats revoking against whatever backend happened to be first.
118    let signer = resolve_order_signer(&state, &id).await?;
119
120    // The operator's username, not the order's account: this revocation was an
121    // administrative act, and a row attributing it to the certificate's owner
122    // would say the opposite of what happened.
123    let outcome = admin::revoke_order(
124        &id,
125        reason,
126        crate::audit::Actor::admin(&auth.user.username),
127        state.audit.client(&request_context).await,
128        state.database.clone(),
129        signer,
130    )
131    .await
132    .map_err(revoke_error)?;
133
134    match outcome {
135        RevokeOutcome::NotFound => Err(not_found(&id)),
136        RevokeOutcome::NotIssued => Err(AdminError::conflict(
137            "order_not_issued",
138            format!("order {id} has no certificate to revoke"),
139        )),
140        RevokeOutcome::AlreadyRevoked => Err(AdminError::conflict(
141            "already_revoked",
142            format!("order {id} was already revoked"),
143        )),
144        RevokeOutcome::Revoked(order) => {
145            tracing::info!(event = "admin_order_revoked",
146                           outcome = "success",
147                           surface = "api",
148                           order_id = %id,
149                           profile = %order.profile,
150                           reason = ?reason,
151                           username = %auth.user.username);
152            let authz_ids = authz_ids(order.id, &state).await?;
153            Ok(Json(admin::render_order_json(
154                &order,
155                &state.config.server.base_url,
156                &authz_ids,
157            )))
158        }
159    }
160}
161
162/// `DELETE /api/orders/{id}` — hard delete, cascading to its authorizations.
163pub async fn delete_order(
164    State(state): State<AdminState>,
165    Path(id): Path<String>,
166    AuthenticatedWrite(auth): AuthenticatedWrite,
167) -> Result<Response, AdminError> {
168    let deleted = admin::delete_order(&id, state.database.clone())
169        .await?
170        .ok_or_else(|| not_found(&id))?;
171
172    tracing::info!(event = "admin_order_deleted",
173                   outcome = "success",
174                   surface = "api",
175                   order_id = %id,
176                   username = %auth.user.username,
177                   cascaded_authorizations = deleted.cascaded);
178    Ok((
179        StatusCode::OK,
180        Json(json!({ "deleted": { "authorizations": deleted.cascaded } })),
181    )
182        .into_response())
183}
184
185/// Renders a page of orders, each with its authorization ids.
186///
187/// Shared with `/api/accounts/{id}/orders` and with the `/ui` order lists,
188/// which all return the same shape.
189pub(crate) async fn render_orders(
190    orders: &[Order],
191    state: &AdminState,
192) -> Result<Vec<Value>, AdminError> {
193    // One query for the whole page, not one per row: a default page of 50 used
194    // to cost 51.
195    let ids: Vec<Uuid> = orders.iter().map(|order| order.id).collect();
196    let mut grouped = Authorization::find_ids_by_orders(&ids, &state.database).await?;
197
198    let items = orders
199        .iter()
200        .map(|order| {
201            admin::render_order_json(
202                order,
203                &state.config.server.base_url,
204                &grouped.remove(&order.id).unwrap_or_default(),
205            )
206        })
207        .collect();
208    Ok(items)
209}
210
211/// The authorization ids `Order::to_json` needs to build its `authorizations`
212/// URLs.
213async fn authz_ids(order_id: Uuid, state: &AdminState) -> Result<Vec<Uuid>, AdminError> {
214    Ok(Authorization::find_by_order(order_id, &state.database)
215        .await?
216        .into_iter()
217        .map(|authz| authz.id)
218        .collect())
219}
220
221/// Maps a failed revocation onto a status the operator can act on.
222///
223/// A signer failure is `502`, not `500`: the CA-side call is what did not
224/// happen, the request itself was fine, and — because `admin::revoke_order`
225/// calls the signer *before* recording anything — the order is still
226/// un-revoked and the request can simply be retried.
227pub(crate) fn revoke_error(error: RevokeError) -> AdminError {
228    match error {
229        RevokeError::BadReason(reason) => AdminError::bad_request(format!(
230            "unsupported revocation reason code {reason} (RFC 5280 §5.3.1)"
231        )),
232        RevokeError::Signer(_) => {
233            tracing::error!(event = "admin_revoke_signer_failed", outcome = "failure", error = %error);
234            AdminError::signer_failed("the signer backend refused the revocation; retry")
235        }
236        RevokeError::Database(inner) => AdminError::from(inner),
237        RevokeError::Internal(detail) => {
238            tracing::error!(event = "admin_revoke_internal_error", outcome = "failure", error = %detail);
239            AdminError::internal()
240        }
241    }
242}
243
244/// The signer that issued `id`'s certificate, or the refusal saying why not.
245///
246/// Revocation is per-profile: another profile's backend holds a different CA,
247/// or none at all, so an order belonging to a profile this process no longer
248/// mounts cannot be revoked here — and saying so plainly beats revoking against
249/// whichever backend happened to be first.
250///
251/// Shared with `pages::orders`, which had the same nine lines and the same
252/// error string character for character. `render_orders` and `revoke_error`
253/// were already shared between the two; this was the one that was not.
254pub(crate) async fn resolve_order_signer(
255    state: &AdminState,
256    id: &str,
257) -> Result<std::sync::Arc<dyn crate::signer::SignerBackend>, AdminError> {
258    let order = Order::find_by_id(id, &state.database)
259        .await?
260        .ok_or_else(|| not_found(id))?;
261    let profile = state.profiles.get(&order.profile).ok_or_else(|| {
262        AdminError::conflict(
263            "profile_not_mounted",
264            format!(
265                "order {id} belongs to profile `{}`, which this configuration does not mount",
266                order.profile
267            ),
268        )
269    })?;
270    Ok(profile.signer.clone())
271}
272
273fn not_found(id: &str) -> AdminError {
274    AdminError::not_found(format!("no such order: {id}"))
275}