acme-proxy 0.4.0

An ACME (RFC 8555) server that issues from a local CA, relays to an upstream CA, or delegates to a script
Documentation
//! `/api/orders` — the orders across every mounted endpoint, and revocation.

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::sqlite::authz::Authorization;
use crate::sqlite::order::{Order, OrderQuery};
use crate::sqlite::status::{OrderStatus, UnknownStatus};
use crate::webadmin::AdminState;
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::paging::{PageParams, page_envelope};
use crate::webadmin::handlers::params::empty_is_absent;
use crate::webadmin::session::{Authenticated, AuthenticatedWrite};

/// The window fields are inline, not `#[serde(flatten)]` — see the note on
/// [`super::accounts::AccountListParams`].
#[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>,
    pub limit: Option<i64>,
    pub offset: Option<i64>,
}

impl OrderListParams {
    /// The `status=` filter, parsed.
    ///
    /// Refused by name rather than passed to SQL: an unknown status matches no
    /// rows, which a caller cannot tell from "nothing is in that state". Both
    /// front ends call this, so `/api/orders?status=typo` and
    /// `/ui/orders?status=typo` give the same answer.
    pub fn parsed_status(&self) -> Result<Option<OrderStatus>, UnknownStatus> {
        self.status.as_deref().map(str::parse).transpose()
    }
}

#[derive(Debug, Deserialize, Default)]
pub struct RevokeRequest {
    /// RFC 5280 §5.3.1 reason code. Absent means "no reason recorded".
    #[serde(default)]
    pub reason: Option<u32>,
}

/// Turns an unparseable `status=` into a `400`, for either front end.
fn bad_status(error: UnknownStatus) -> AdminError {
    AdminError::with_code(StatusCode::BAD_REQUEST, "invalid_status", error.to_string())
}

/// `GET /api/orders?profile=&accountId=&status=&limit=&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);
    // Parsed before the move, since the helper borrows `params`.
    let status = params.parsed_status().map_err(bad_status)?;
    let query = OrderQuery {
        profile: params.profile,
        account_id: params.account_id,
        status,
        limit: page.limit,
        offset: page.offset,
    };
    let (orders, total) = Order::search(&query, &state.database).await?;

    let items = render_orders(&orders, &state).await?;
    Ok(Json(page_envelope(items, total, page)))
}

/// `GET /api/orders/{id}` — the order plus its authorizations and challenges.
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,
    )))
}

/// `POST /api/orders/{id}/revoke`
///
/// The signer comes from **the order's own profile**, not from any ambient
/// default: two profiles can hold two different CAs, and revoking against the
/// wrong one would record nothing useful and leave the real CRL untouched.
///
/// Better than the CLI's equivalent, which has to rebuild a backend from
/// configuration: here the live, deduplicated instance is already in hand, so
/// a `local_ca` CRL is regenerated by the very object that serves `GET /crl`.
pub async fn revoke_order(
    State(state): State<AdminState>,
    Path(id): Path<String>,
    request_context: crate::audit::RequestContext,
    AuthenticatedWrite(auth): AuthenticatedWrite,
    body: Option<Json<RevokeRequest>>,
) -> Result<Json<Value>, AdminError> {
    let reason = body.and_then(|Json(body)| body.reason);

    // Resolve the profile before doing anything: an order belonging to a
    // profile this process no longer mounts cannot be revoked here, and saying
    // so plainly beats revoking against whatever backend happened to be first.
    let signer = resolve_order_signer(&state, &id).await?;

    // The operator's username, not the order's account: this revocation was an
    // administrative act, and a row attributing it to the certificate's owner
    // would say the opposite of what happened.
    let outcome = admin::revoke_order(
        &id,
        reason,
        crate::audit::Actor::admin(&auth.user.username),
        state.audit.client(&request_context).await,
        state.database.clone(),
        signer,
    )
    .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 = "api",
                           order_id = %id,
                           profile = %order.profile,
                           reason = ?reason,
                           username = %auth.user.username);
            let authz_ids = authz_ids(order.id, &state).await?;
            Ok(Json(admin::render_order_json(
                &order,
                &state.config.server.base_url,
                &authz_ids,
            )))
        }
    }
}

/// `DELETE /api/orders/{id}` — hard delete, cascading to its authorizations.
pub async fn delete_order(
    State(state): State<AdminState>,
    Path(id): Path<String>,
    AuthenticatedWrite(auth): AuthenticatedWrite,
) -> Result<Response, AdminError> {
    let deleted = admin::delete_order(&id, state.database.clone())
        .await?
        .ok_or_else(|| not_found(&id))?;

    tracing::info!(event = "admin_order_deleted",
                   outcome = "success",
                   surface = "api",
                   order_id = %id,
                   username = %auth.user.username,
                   cascaded_authorizations = deleted.cascaded);
    Ok((
        StatusCode::OK,
        Json(json!({ "deleted": { "authorizations": deleted.cascaded } })),
    )
        .into_response())
}

/// Renders a page of orders, each with its authorization ids.
///
/// Shared with `/api/accounts/{id}/orders` and with the `/ui` order lists,
/// which all return the same shape.
pub(crate) async fn render_orders(
    orders: &[Order],
    state: &AdminState,
) -> Result<Vec<Value>, AdminError> {
    // One query for the whole page, not one per row: a default page of 50 used
    // to cost 51.
    let ids: Vec<Uuid> = orders.iter().map(|order| order.id).collect();
    let mut grouped = Authorization::find_ids_by_orders(&ids, &state.database).await?;

    let items = orders
        .iter()
        .map(|order| {
            admin::render_order_json(
                order,
                &state.config.server.base_url,
                &grouped.remove(&order.id).unwrap_or_default(),
            )
        })
        .collect();
    Ok(items)
}

/// The authorization ids `Order::to_json` needs to build its `authorizations`
/// URLs.
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())
}

/// Maps a failed revocation onto a status the operator can act on.
///
/// A signer failure is `502`, not `500`: the CA-side call is what did not
/// happen, the request itself was fine, and — because `admin::revoke_order`
/// calls the signer *before* recording anything — the order is still
/// un-revoked and the request can simply be retried.
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(_) => {
            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()
        }
    }
}

/// The signer that issued `id`'s certificate, or the refusal saying why not.
///
/// Revocation is per-profile: another profile's backend holds a different CA,
/// or none at all, so an order belonging to a profile this process no longer
/// mounts cannot be revoked here — and saying so plainly beats revoking against
/// whichever backend happened to be first.
///
/// Shared with `pages::orders`, which had the same nine lines and the same
/// error string character for character. `render_orders` and `revoke_error`
/// were already shared between the two; this was the one that was not.
pub(crate) async fn resolve_order_signer(
    state: &AdminState,
    id: &str,
) -> Result<std::sync::Arc<dyn crate::signer::SignerBackend>, 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.signer.clone())
}

fn not_found(id: &str) -> AdminError {
    AdminError::not_found(format!("no such order: {id}"))
}