acme-proxy-admin 0.6.1

The operation layer and web admin panel of acme-proxy (internal crate, no semver promise)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! `/api/orders` — the orders across every mounted endpoint, and revocation.
//!
//! Each handler is extractor, an `admin` operation shared with the CLI and the
//! `/ui` pages, and `admin::render`'s JSON, so the three cannot describe one
//! row differently. Revocation goes through the same queue and ledger
//! `POST /revokeCert` does; this process holds no signing backend.

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;

/// 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>,
    /// Exact identifier match (case-insensitive).
    #[serde(default, deserialize_with = "empty_is_absent")]
    pub identifier: Option<String>,
    /// Substring identifier match (case-insensitive). Mutually exclusive with
    /// `identifier` — [`OrderListParams::check_identifier_filters`] rejects both.
    #[serde(
        rename = "identifierContains",
        default,
        deserialize_with = "empty_is_absent"
    )]
    pub identifier_contains: Option<String>,
    /// Exact issued-certificate serial match (hex, no separators).
    #[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 {
    /// 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()
    }

    /// `identifier` and `identifierContains` are two spellings of one filter and
    /// asking for both at once is a caller mistake, not a narrower query. Both
    /// front ends call this so `/api` and `/ui` refuse it the same way; the
    /// message is returned bare so each can wrap it in its own error shape.
    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(())
    }
}

/// Turns the `identifier`/`identifierContains` conflict into a `400`.
fn bad_identifier_filters(message: &'static str) -> AdminError {
    AdminError::with_code(
        StatusCode::BAD_REQUEST,
        "conflicting_identifier_filter",
        message,
    )
}

/// The optional body of `POST /api/orders/{id}/revoke`.
#[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>,
}

/// The listing query `params` asks for, refusals and all.
///
/// Both front ends build it here: the two copies this replaced had the same
/// three refusals worded twice, and the page answered a generic
/// `bad_request` where the API named `invalid_status` or
/// `conflicting_identifier_filter`. `Order::search` is the one listing filter
/// (see `crates/CLAUDE.md`), so this is the one place its parameters are
/// assembled.
pub(crate) fn order_query(
    params: OrderListParams,
    page: crate::webadmin::handlers::paging::Page,
) -> Result<OrderQuery, AdminError> {
    // Parsed before the move, since the helpers borrow `params`.
    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,
    })
}

/// `GET /api/orders?profile=&accountId=&status=&identifier=&identifierContains=&certSerial=&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);
    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)))
}

/// `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.
///
/// No backend is built here, and none is held: the panel runs in the `admin`
/// role, which has no signing key. A `local_ca` revocation is a ledger row and
/// a queued regeneration; a `relay`'s or a script's is a `signer_revoke` job
/// the worker runs. That is the same route `POST /revokeCert` and
/// `acme-proxy order revoke` take — see `acme_proxy_signer::revocation_route`.
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())
        }
        // Accepted, not refused: the worker revokes, and the job is where to
        // follow it.
        Revoked::Queued(job) => Ok((
            StatusCode::ACCEPTED,
            Json(serde_json::json!({ "status": "queued", "job": job.to_string() })),
        )
            .into_response()),
    }
}

/// What an accepted revocation came to.
pub(crate) enum Revoked {
    /// Revoked now; the order as it reads afterwards.
    Now(Box<Order>),
    /// Queued for the worker as this job, which had not answered by the end of
    /// the wait. Not a failure: the revocation carries on.
    Queued(Uuid),
}

/// Revokes an order's certificate against **its own profile's** revocation
/// route, attributed to the operator rather than the certificate's owner. The
/// audit row is written by [`admin::revoke_order`]; this adds the refusals'
/// wording and the log line.
///
/// `404` for no such order or an unmounted profile, `409 order_not_issued` and
/// `409 already_revoked` for an order whose state does not allow it.
pub(crate) async fn apply_revoke_order(
    state: &AdminState,
    caller: &Caller<'_>,
    id: &str,
    reason: Option<u32>,
) -> Result<Revoked, AdminError> {
    // 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 profile = resolve_order_profile(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 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))
        }
    }
}

/// How a request on this listener revokes for a profile whose read side
/// answered `route`: a local CA's ledger, or the queue — never a backend, which
/// only the `worker` role holds. Waits as long as an ACME request would.
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),
    )
}

/// `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,
    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())
}

/// Hard-deletes an order and its authorizations, answering how many
/// authorizations went with it. `409 live_certificates` while it holds one.
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)
}

/// Renders a page of orders, each with its authorization ids
/// ([`admin::orders_json`]).
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?)
}

/// 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 worker's queued revocation was retired without revoking — the
/// request itself was fine, and since the order is only ever stamped after the
/// backend succeeds, it 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(_) | 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()
        }
    }
}

/// The profile that issued `id`'s certificate — its signer and its notifier —
/// 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_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))
}