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