Skip to main content

acme_proxy_admin/webadmin/handlers/
orders.rs

1//! `/api/orders` — the orders across every mounted endpoint, and revocation.
2//!
3//! Each handler is extractor, an `admin` operation shared with the CLI and the
4//! `/ui` pages, and `admin::render`'s JSON, so the three cannot describe one
5//! row differently. Revocation goes through the same queue and ledger
6//! `POST /revokeCert` does; this process holds no signing backend.
7
8use axum::Json;
9use axum::extract::{Path, Query, State};
10use axum::http::StatusCode;
11use axum::response::{IntoResponse, Response};
12use serde::Deserialize;
13use serde_json::{Value, json};
14use uuid::Uuid;
15
16use crate::admin;
17use crate::admin::ops::{RevokeError, RevokeOutcome};
18use crate::webadmin::AdminState;
19use crate::webadmin::error::AdminError;
20use crate::webadmin::handlers::Caller;
21use crate::webadmin::handlers::paging::{PageParams, page_envelope};
22use crate::webadmin::handlers::params::{bad_status, empty_is_absent, empty_is_absent_serial};
23use crate::webadmin::session::{Authenticated, AuthenticatedWrite};
24use acme_proxy_store::authz::Authorization;
25use acme_proxy_store::order::Order;
26use acme_proxy_store::order::OrderQuery;
27use acme_proxy_store::status::OrderStatus;
28use acme_proxy_store::status::UnknownStatus;
29
30/// The window fields are inline, not `#[serde(flatten)]` — see the note on
31/// [`super::accounts::AccountListParams`].
32#[derive(Debug, Deserialize, Default)]
33pub struct OrderListParams {
34    #[serde(default, deserialize_with = "empty_is_absent")]
35    pub profile: Option<String>,
36    #[serde(rename = "accountId", default, deserialize_with = "empty_is_absent")]
37    pub account_id: Option<String>,
38    #[serde(default, deserialize_with = "empty_is_absent")]
39    pub status: Option<String>,
40    /// Exact identifier match (case-insensitive).
41    #[serde(default, deserialize_with = "empty_is_absent")]
42    pub identifier: Option<String>,
43    /// Substring identifier match (case-insensitive). Mutually exclusive with
44    /// `identifier` — [`OrderListParams::check_identifier_filters`] rejects both.
45    #[serde(
46        rename = "identifierContains",
47        default,
48        deserialize_with = "empty_is_absent"
49    )]
50    pub identifier_contains: Option<String>,
51    /// Exact issued-certificate serial match (hex, no separators).
52    #[serde(
53        rename = "certSerial",
54        default,
55        deserialize_with = "empty_is_absent_serial"
56    )]
57    pub cert_serial: Option<String>,
58    pub limit: Option<i64>,
59    pub offset: Option<i64>,
60}
61
62impl OrderListParams {
63    /// The `status=` filter, parsed.
64    ///
65    /// Refused by name rather than passed to SQL: an unknown status matches no
66    /// rows, which a caller cannot tell from "nothing is in that state". Both
67    /// front ends call this, so `/api/orders?status=typo` and
68    /// `/ui/orders?status=typo` give the same answer.
69    pub fn parsed_status(&self) -> Result<Option<OrderStatus>, UnknownStatus> {
70        self.status.as_deref().map(str::parse).transpose()
71    }
72
73    /// `identifier` and `identifierContains` are two spellings of one filter and
74    /// asking for both at once is a caller mistake, not a narrower query. Both
75    /// front ends call this so `/api` and `/ui` refuse it the same way; the
76    /// message is returned bare so each can wrap it in its own error shape.
77    pub fn check_identifier_filters(&self) -> Result<(), &'static str> {
78        if self.identifier.is_some() && self.identifier_contains.is_some() {
79            return Err("give either identifier or identifierContains, not both");
80        }
81        Ok(())
82    }
83}
84
85/// Turns the `identifier`/`identifierContains` conflict into a `400`.
86fn bad_identifier_filters(message: &'static str) -> AdminError {
87    AdminError::with_code(
88        StatusCode::BAD_REQUEST,
89        "conflicting_identifier_filter",
90        message,
91    )
92}
93
94/// The optional body of `POST /api/orders/{id}/revoke`.
95#[derive(Debug, Deserialize, Default)]
96pub struct RevokeRequest {
97    /// RFC 5280 §5.3.1 reason code. Absent means "no reason recorded".
98    #[serde(default)]
99    pub reason: Option<u32>,
100}
101
102/// The listing query `params` asks for, refusals and all.
103///
104/// Both front ends build it here: the two copies this replaced had the same
105/// three refusals worded twice, and the page answered a generic
106/// `bad_request` where the API named `invalid_status` or
107/// `conflicting_identifier_filter`. `Order::search` is the one listing filter
108/// (see `crates/CLAUDE.md`), so this is the one place its parameters are
109/// assembled.
110pub(crate) fn order_query(
111    params: OrderListParams,
112    page: crate::webadmin::handlers::paging::Page,
113) -> Result<OrderQuery, AdminError> {
114    // Parsed before the move, since the helpers borrow `params`.
115    let status = params.parsed_status().map_err(bad_status)?;
116    params
117        .check_identifier_filters()
118        .map_err(bad_identifier_filters)?;
119    Ok(OrderQuery {
120        profile: params.profile,
121        account_id: params.account_id,
122        status,
123        identifier: params.identifier,
124        identifier_contains: params.identifier_contains,
125        cert_serial: params.cert_serial,
126        limit: page.limit,
127        offset: page.offset,
128    })
129}
130
131/// `GET /api/orders?profile=&accountId=&status=&identifier=&identifierContains=&certSerial=&limit=&offset=`
132pub async fn list_orders(
133    State(state): State<AdminState>,
134    Query(params): Query<OrderListParams>,
135    _auth: Authenticated,
136) -> Result<Json<Value>, AdminError> {
137    let page = PageParams::from(params.limit, params.offset).resolve(&state.config);
138    let query = order_query(params, page)?;
139    let (orders, total) = Order::search(&query, &state.database).await?;
140
141    let items = render_orders(&orders, &state).await?;
142    Ok(Json(page_envelope(items, total, page)))
143}
144
145/// `GET /api/orders/{id}` — the order plus its authorizations and challenges.
146pub async fn get_order(
147    State(state): State<AdminState>,
148    Path(id): Path<String>,
149    _auth: Authenticated,
150) -> Result<Json<Value>, AdminError> {
151    let detail = admin::load_order_detail(&id, state.database.clone())
152        .await?
153        .ok_or_else(|| not_found(&id))?;
154    Ok(Json(admin::render_order_detail_json(
155        &detail,
156        &state.config.server.base_url,
157    )))
158}
159
160/// `POST /api/orders/{id}/revoke`
161///
162/// The signer comes from **the order's own profile**, not from any ambient
163/// default: two profiles can hold two different CAs, and revoking against the
164/// wrong one would record nothing useful and leave the real CRL untouched.
165///
166/// No backend is built here, and none is held: the panel runs in the `admin`
167/// role, which has no signing key. A `local_ca` revocation is a ledger row and
168/// a queued regeneration; a `relay`'s or a script's is a `signer_revoke` job
169/// the worker runs. That is the same route `POST /revokeCert` and
170/// `acme-proxy order revoke` take — see `acme_proxy_signer::revocation_route`.
171pub async fn revoke_order(
172    State(state): State<AdminState>,
173    Path(id): Path<String>,
174    request_context: acme_proxy_core::audit::RequestContext,
175    AuthenticatedWrite(auth): AuthenticatedWrite,
176    body: Option<Json<RevokeRequest>>,
177) -> Result<Response, AdminError> {
178    let reason = body.and_then(|Json(body)| body.reason);
179    match apply_revoke_order(&state, &Caller::api(&auth, &request_context), &id, reason).await? {
180        Revoked::Now(order) => {
181            let authz_ids = authz_ids(order.id, &state).await?;
182            Ok(Json(admin::render_order_json(
183                &order,
184                &state.config.server.base_url,
185                &authz_ids,
186            ))
187            .into_response())
188        }
189        // Accepted, not refused: the worker revokes, and the job is where to
190        // follow it.
191        Revoked::Queued(job) => Ok((
192            StatusCode::ACCEPTED,
193            Json(serde_json::json!({ "status": "queued", "job": job.to_string() })),
194        )
195            .into_response()),
196    }
197}
198
199/// What an accepted revocation came to.
200pub(crate) enum Revoked {
201    /// Revoked now; the order as it reads afterwards.
202    Now(Box<Order>),
203    /// Queued for the worker as this job, which had not answered by the end of
204    /// the wait. Not a failure: the revocation carries on.
205    Queued(Uuid),
206}
207
208/// Revokes an order's certificate against **its own profile's** revocation
209/// route, attributed to the operator rather than the certificate's owner. The
210/// audit row is written by [`admin::revoke_order`]; this adds the refusals'
211/// wording and the log line.
212///
213/// `404` for no such order or an unmounted profile, `409 order_not_issued` and
214/// `409 already_revoked` for an order whose state does not allow it.
215pub(crate) async fn apply_revoke_order(
216    state: &AdminState,
217    caller: &Caller<'_>,
218    id: &str,
219    reason: Option<u32>,
220) -> Result<Revoked, AdminError> {
221    // Resolve the profile before doing anything: an order belonging to a
222    // profile this process no longer mounts cannot be revoked here, and saying
223    // so plainly beats revoking against whatever backend happened to be first.
224    let profile = resolve_order_profile(state, id).await?;
225
226    // The operator's username, not the order's account: this revocation was an
227    // administrative act, and a row attributing it to the certificate's owner
228    // would say the opposite of what happened.
229    let route = profile.signer_info.revocation_route();
230    let outcome = admin::revoke_order(
231        id,
232        reason,
233        acme_proxy_core::audit::Actor::admin(caller.username()),
234        state.audit.client(caller.request).await,
235        acme_proxy_protocol::acme::revoke::Revocations {
236            database: &state.database,
237            audit: &state.audit,
238            notify: Some(&profile.notify),
239            revoker: revoker(state, &route),
240        },
241    )
242    .await
243    .map_err(revoke_error)?;
244
245    match outcome {
246        RevokeOutcome::NotFound => Err(not_found(id)),
247        RevokeOutcome::NotIssued => Err(AdminError::conflict(
248            "order_not_issued",
249            format!("order {id} has no certificate to revoke"),
250        )),
251        RevokeOutcome::AlreadyRevoked => Err(AdminError::conflict(
252            "already_revoked",
253            format!("order {id} was already revoked"),
254        )),
255        RevokeOutcome::Revoked(order) => {
256            tracing::info!(event = "admin_order_revoked",
257                           outcome = "success",
258                           surface = caller.surface,
259                           order_id = %id,
260                           profile = %order.profile,
261                           reason = ?reason,
262                           username = %caller.username());
263            Ok(Revoked::Now(order))
264        }
265        RevokeOutcome::Queued(job) => {
266            tracing::info!(event = "admin_order_revoke_queued",
267                           outcome = "progress",
268                           surface = caller.surface,
269                           order_id = %id,
270                           job_id = %job,
271                           username = %caller.username());
272            Ok(Revoked::Queued(job))
273        }
274    }
275}
276
277/// How a request on this listener revokes for a profile whose read side
278/// answered `route`: a local CA's ledger, or the queue — never a backend, which
279/// only the `worker` role holds. Waits as long as an ACME request would.
280pub(crate) fn revoker<'a>(
281    state: &'a AdminState,
282    route: &'a acme_proxy_signer::RevocationRoute,
283) -> acme_proxy_protocol::acme::revoke::Revoker<'a> {
284    acme_proxy_protocol::acme::revoke::Revoker::for_route(
285        route,
286        &state.jobs,
287        acme_proxy_protocol::acme::revoke::request_wait(state.config.server.request_timeout_ms),
288    )
289}
290
291/// `DELETE /api/orders/{id}` — hard delete, cascading to its authorizations.
292pub async fn delete_order(
293    State(state): State<AdminState>,
294    Path(id): Path<String>,
295    AuthenticatedWrite(auth): AuthenticatedWrite,
296    request_context: acme_proxy_core::audit::RequestContext,
297) -> Result<Response, AdminError> {
298    let cascaded = apply_delete_order(&state, &Caller::api(&auth, &request_context), &id).await?;
299    Ok((
300        StatusCode::OK,
301        Json(json!({ "deleted": { "authorizations": cascaded } })),
302    )
303        .into_response())
304}
305
306/// Hard-deletes an order and its authorizations, answering how many
307/// authorizations went with it. `409 live_certificates` while it holds one.
308pub(crate) async fn apply_delete_order(
309    state: &AdminState,
310    caller: &Caller<'_>,
311    id: &str,
312) -> Result<u64, AdminError> {
313    let subject = Order::find_by_id(id, &state.database).await?;
314    let deleted = match admin::delete_order(id, state.database.clone()).await? {
315        admin::Deletion::NotFound => return Err(not_found(id)),
316        admin::Deletion::LiveCertificates(live) => {
317            return Err(AdminError::conflict(
318                "live_certificates",
319                admin::live_certificates_refusal(&format!("order {id}"), live),
320            ));
321        }
322        admin::Deletion::Deleted(deleted) => deleted,
323    };
324
325    if let Some(order) = subject {
326        state
327            .record_admin_action(caller.request, caller.username(), |actor, client| {
328                acme_proxy_jobs::auditor::admin::order_deleted(
329                    actor,
330                    client,
331                    &order,
332                    deleted.cascaded,
333                )
334            })
335            .await;
336    }
337    tracing::info!(event = "admin_order_deleted",
338                   outcome = "success",
339                   surface = caller.surface,
340                   order_id = %id,
341                   username = %caller.username(),
342                   cascaded_authorizations = deleted.cascaded);
343    Ok(deleted.cascaded)
344}
345
346/// Renders a page of orders, each with its authorization ids
347/// ([`admin::orders_json`]).
348pub(crate) async fn render_orders(
349    orders: &[Order],
350    state: &AdminState,
351) -> Result<Vec<Value>, AdminError> {
352    Ok(admin::orders_json(orders, &state.config.server.base_url, &state.database).await?)
353}
354
355/// The authorization ids `Order::to_json` needs to build its `authorizations`
356/// URLs.
357async fn authz_ids(order_id: Uuid, state: &AdminState) -> Result<Vec<Uuid>, AdminError> {
358    Ok(Authorization::find_by_order(order_id, &state.database)
359        .await?
360        .into_iter()
361        .map(|authz| authz.id)
362        .collect())
363}
364
365/// Maps a failed revocation onto a status the operator can act on.
366///
367/// A signer failure is `502`, not `500`: the CA-side call is what did not
368/// happen — the worker's queued revocation was retired without revoking — the
369/// request itself was fine, and since the order is only ever stamped after the
370/// backend succeeds, it is still un-revoked and the request can simply be
371/// retried.
372pub(crate) fn revoke_error(error: RevokeError) -> AdminError {
373    match error {
374        RevokeError::BadReason(reason) => AdminError::bad_request(format!(
375            "unsupported revocation reason code {reason} (RFC 5280 §5.3.1)"
376        )),
377        RevokeError::Signer(_) | RevokeError::Abandoned { .. } => {
378            tracing::error!(event = "admin_revoke_signer_failed", outcome = "failure", error = %error);
379            AdminError::signer_failed("the signer backend refused the revocation; retry")
380        }
381        RevokeError::Database(inner) => AdminError::from(inner),
382        RevokeError::Internal(detail) => {
383            tracing::error!(event = "admin_revoke_internal_error", outcome = "failure", error = %detail);
384            AdminError::internal()
385        }
386    }
387}
388
389/// The profile that issued `id`'s certificate — its signer and its notifier —
390/// or the refusal saying why not.
391///
392/// Revocation is per-profile: another profile's backend holds a different CA,
393/// or none at all, so an order belonging to a profile this process no longer
394/// mounts cannot be revoked here — and saying so plainly beats revoking against
395/// whichever backend happened to be first.
396///
397/// Shared with `pages::orders`, which had the same nine lines and the same
398/// error string character for character. `render_orders` and `revoke_error`
399/// were already shared between the two; this was the one that was not.
400pub(crate) async fn resolve_order_profile(
401    state: &AdminState,
402    id: &str,
403) -> Result<std::sync::Arc<acme_proxy_protocol::profile::Profile>, AdminError> {
404    let order = Order::find_by_id(id, &state.database)
405        .await?
406        .ok_or_else(|| not_found(id))?;
407    let profile = state.profiles.get(&order.profile).ok_or_else(|| {
408        AdminError::conflict(
409            "profile_not_mounted",
410            format!(
411                "order {id} belongs to profile `{}`, which this configuration does not mount",
412                order.profile
413            ),
414        )
415    })?;
416    Ok(profile.clone())
417}
418
419fn not_found(id: &str) -> AdminError {
420    AdminError::not_found(crate::admin::subject::Subject::Order.missing(id))
421}