Skip to main content

acme_proxy_admin/webadmin/handlers/
eab.rs

1//! `/api/eab` — External Account Binding credentials.
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. The secret appears once, in the answer to the create
6//! request, and never in a listing or a show.
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;
14
15use crate::admin;
16use crate::webadmin::AdminState;
17use crate::webadmin::error::AdminError;
18use crate::webadmin::handlers::Caller;
19use crate::webadmin::handlers::paging::{PageParams, page_envelope};
20use crate::webadmin::handlers::params::empty_is_absent;
21use crate::webadmin::session::{Authenticated, AuthenticatedWrite};
22use acme_proxy_store::eab::BoundAccounts;
23use acme_proxy_store::eab::DeletedEab;
24use acme_proxy_store::eab::Eab;
25use acme_proxy_store::eab::EabDeletion;
26
27/// The optional body of `POST /api/eab`. An empty string reads as absent.
28#[derive(Debug, Deserialize, Default)]
29pub struct CreateEab {
30    #[serde(default, deserialize_with = "empty_is_absent")]
31    pub label: Option<String>,
32    /// Bind the credential to one endpoint. Absent means every profile.
33    #[serde(default, deserialize_with = "empty_is_absent")]
34    pub profile: Option<String>,
35}
36
37/// The query of `DELETE /api/eab/{kid}` and `DELETE /ui/eab/{kid}`.
38#[derive(Debug, Deserialize, Default)]
39pub struct DeleteEabParams {
40    /// `keep` (the default), `deactivate` or `delete`: what happens to the
41    /// accounts the credential bound. See [`BoundAccounts`].
42    #[serde(default, deserialize_with = "empty_is_absent")]
43    pub accounts: Option<String>,
44}
45
46impl DeleteEabParams {
47    /// The mode, refusing an unknown one **by name** and listing the
48    /// alternatives, as `order list --status` does: guessing `keep` for a typo
49    /// of `delete` would answer a destructive request with a different one.
50    pub(crate) fn resolve(&self) -> Result<BoundAccounts, AdminError> {
51        let Some(value) = self.accounts.as_deref() else {
52            return Ok(BoundAccounts::Keep);
53        };
54        BoundAccounts::parse(value).ok_or_else(|| {
55            let known: Vec<&str> = BoundAccounts::ALL
56                .iter()
57                .map(|mode| mode.as_str())
58                .collect();
59            AdminError::bad_request(format!(
60                "unknown accounts mode `{value}`; expected one of: {}",
61                known.join(", ")
62            ))
63        })
64    }
65}
66
67/// `GET /api/eab?limit=&offset=` — one page of credentials. Never the secret.
68///
69/// Takes `Query<PageParams>` directly rather than declaring the window inline:
70/// the `#[serde(flatten)]` trap documented on `AccountListParams` needs a
71/// filter to flatten *around*, and this listing has none. Newest first, like
72/// every other listing — see [`Eab::search`] for why its tiebreak runs the
73/// same way as its primary key rather than against it.
74pub async fn list_eab(
75    State(state): State<AdminState>,
76    Query(params): Query<PageParams>,
77    _auth: Authenticated,
78) -> Result<Json<Value>, AdminError> {
79    let page = params.resolve(&state.config);
80    let (keys, total) = Eab::search(page.limit, page.offset, &state.database).await?;
81    let items: Vec<Value> = keys.iter().map(admin::render_eab_json).collect();
82    Ok(Json(page_envelope(items, total, page)))
83}
84
85/// `GET /api/eab/{kid}` — one credential. Never the secret.
86pub async fn get_eab(
87    State(state): State<AdminState>,
88    Path(kid): Path<String>,
89    _auth: Authenticated,
90) -> Result<Json<Value>, AdminError> {
91    let eab = Eab::find_any_by_kid(&kid, &state.database)
92        .await?
93        .ok_or_else(|| not_found(&kid))?;
94    Ok(Json(admin::render_eab_json(&eab)))
95}
96
97/// `POST /api/eab` — mint a credential.
98///
99/// **The only response in this API that carries a secret.** It is shown once
100/// and is not recoverable afterwards, exactly as `acme-proxy eab create`
101/// behaves — a lost credential is replaced, never read back. The log records
102/// the kid and never the secret.
103pub async fn create_eab(
104    State(state): State<AdminState>,
105    AuthenticatedWrite(auth): AuthenticatedWrite,
106    request_context: acme_proxy_core::audit::RequestContext,
107    body: Option<Json<CreateEab>>,
108) -> Result<Response, AdminError> {
109    let Json(body) = body.unwrap_or_default();
110    let eab = apply_create_eab(
111        &state,
112        &Caller::api(&auth, &request_context),
113        body.label,
114        body.profile,
115        "omit `profile`",
116    )
117    .await?;
118
119    Ok((
120        StatusCode::CREATED,
121        Json(admin::render_eab_created_json(&eab)),
122    )
123        .into_response())
124}
125
126/// `POST /api/eab/{kid}/revoke`
127///
128/// A `POST` to `revoke` rather than a `DELETE`, because the row survives: the
129/// model moves it to `revoked` and the CLI calls it the same thing. Accounts
130/// already bound under it are deliberately unaffected, and keep resolving to it
131/// — which is what separates this from [`delete_eab`].
132pub async fn revoke_eab(
133    State(state): State<AdminState>,
134    Path(kid): Path<String>,
135    AuthenticatedWrite(auth): AuthenticatedWrite,
136    request_context: acme_proxy_core::audit::RequestContext,
137) -> Result<StatusCode, AdminError> {
138    apply_revoke_eab(&state, &Caller::api(&auth, &request_context), &kid).await?;
139    Ok(StatusCode::NO_CONTENT)
140}
141
142/// `DELETE /api/eab/{kid}?accounts=keep|deactivate|delete`
143///
144/// Removes the row, where [`revoke_eab`] keeps it. Answers `200` with what it
145/// did to the accounts rather than a bare `204`, the `DELETE
146/// /api/accounts/{id}` shape: `deleted` counts accounts and the orders that
147/// cascaded with them, `deactivatedAccounts` those moved to `deactivated`, and
148/// `keptAccounts` those still in the table naming a credential that is now
149/// gone. `409 live_certificates` when `accounts=delete` would take a live
150/// certificate's order with it; nothing changes then.
151pub async fn delete_eab(
152    State(state): State<AdminState>,
153    Path(kid): Path<String>,
154    Query(params): Query<DeleteEabParams>,
155    AuthenticatedWrite(auth): AuthenticatedWrite,
156    request_context: acme_proxy_core::audit::RequestContext,
157) -> Result<Json<Value>, AdminError> {
158    let accounts = params.resolve()?;
159    let deleted = apply_delete_eab(
160        &state,
161        &Caller::api(&auth, &request_context),
162        &kid,
163        accounts,
164    )
165    .await?;
166
167    let orders: u64 = deleted.deleted.iter().map(|(_, orders)| orders).sum();
168    Ok(Json(serde_json::json!({
169        "deleted": { "accounts": deleted.deleted.len(), "orders": orders },
170        "deactivatedAccounts": deleted.deactivated.len(),
171        "keptAccounts": deleted.remaining,
172    })))
173}
174
175/// Mints a credential: the mounted-profile check, the row, its audit row and
176/// the log line. `hint` is the front end's wording of "leave the profile out"
177/// (see [`require_mounted_profile`]).
178pub(crate) async fn apply_create_eab(
179    state: &AdminState,
180    caller: &Caller<'_>,
181    label: Option<String>,
182    profile: Option<String>,
183    hint: &str,
184) -> Result<Eab, AdminError> {
185    require_mounted_profile(state, profile.as_deref(), hint)?;
186
187    let eab = Eab::create(label, profile, &state.database).await?;
188    state
189        .record_admin_action(caller.request, caller.username(), |actor, client| {
190            acme_proxy_jobs::auditor::admin::eab_created(
191                actor,
192                client,
193                &eab.kid.to_string(),
194                eab.profile.as_deref(),
195                eab.label.as_deref(),
196            )
197        })
198        .await;
199    tracing::info!(event = "admin_eab_created",
200                   outcome = "success",
201                   surface = caller.surface,
202                   kid = %eab.kid,
203                   profile = ?eab.profile,
204                   username = %caller.username());
205    Ok(eab)
206}
207
208/// Revokes a credential, keeping its row. Idempotent, but the row has to
209/// exist, or the operator is being told something happened to nothing. Only
210/// the revoke that changed something records a row.
211pub(crate) async fn apply_revoke_eab(
212    state: &AdminState,
213    caller: &Caller<'_>,
214    kid: &str,
215) -> Result<(), AdminError> {
216    let subject = Eab::find_any_by_kid(kid, &state.database).await?;
217    if !Eab::revoke(kid, &state.database).await? {
218        return Err(not_found(kid));
219    }
220    if let Some(eab) = subject.as_ref().filter(|eab| eab.status == "active") {
221        state
222            .record_admin_action(caller.request, caller.username(), |actor, client| {
223                acme_proxy_jobs::auditor::admin::eab_revoked(
224                    actor,
225                    client,
226                    kid,
227                    eab.profile.as_deref(),
228                )
229            })
230            .await;
231    }
232    tracing::info!(event = "admin_eab_revoked",
233                   outcome = "success",
234                   surface = caller.surface,
235                   kid = %kid,
236                   username = %caller.username());
237    Ok(())
238}
239
240/// Deletes a credential, doing `accounts` to the accounts it bound. `404` for
241/// no such credential, `409 live_certificates` when `accounts=delete` would
242/// take a live certificate's order with it; nothing changes then.
243pub(crate) async fn apply_delete_eab(
244    state: &AdminState,
245    caller: &Caller<'_>,
246    kid: &str,
247    accounts: BoundAccounts,
248) -> Result<DeletedEab, AdminError> {
249    let deleted = deleted_or_refused(
250        kid,
251        admin::delete_eab(kid, accounts, state.database.clone()).await?,
252    )?;
253
254    state
255        .record_admin_actions(caller.request, caller.username(), |actor, client| {
256            acme_proxy_jobs::auditor::admin::eab_deleted_records(actor, client, &deleted)
257        })
258        .await;
259    tracing::info!(event = "admin_eab_deleted",
260                   outcome = "success",
261                   surface = caller.surface,
262                   kid = %kid,
263                   accounts = accounts.as_str(),
264                   username = %caller.username());
265    Ok(deleted)
266}
267
268/// An [`EabDeletion`] as either what was deleted or the refusal both front ends
269/// answer with.
270pub(crate) fn deleted_or_refused(
271    kid: &str,
272    deletion: EabDeletion,
273) -> Result<DeletedEab, AdminError> {
274    match deletion {
275        EabDeletion::NotFound => Err(not_found(kid)),
276        EabDeletion::LiveCertificates {
277            accounts,
278            certificates,
279        } => Err(AdminError::conflict(
280            "live_certificates",
281            admin::eab_live_certificates_refusal(kid, accounts, certificates),
282        )),
283        EabDeletion::Deleted(deleted) => Ok(deleted),
284    }
285}
286
287/// Refuses a credential scoped to an endpoint this process does not serve, in
288/// this state's terms; the rule itself is
289/// [`ops::unmounted_profile_refusal`](crate::admin::ops::unmounted_profile_refusal),
290/// which `eab create` on the host CLI asks too.
291pub(crate) fn require_mounted_profile(
292    state: &AdminState,
293    profile: Option<&str>,
294    hint: &str,
295) -> Result<(), AdminError> {
296    match crate::admin::ops::unmounted_profile_refusal(
297        |name| state.profiles.contains_key(name),
298        profile,
299        hint,
300    ) {
301        Some(message) => Err(AdminError::bad_request(message)),
302        None => Ok(()),
303    }
304}
305
306fn not_found(kid: &str) -> AdminError {
307    AdminError::not_found(crate::admin::subject::Subject::EabCredential.missing(kid))
308}