Skip to main content

acme_proxy/webadmin/handlers/
eab.rs

1//! `/api/eab` — External Account Binding credentials.
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;
9
10use crate::admin;
11use crate::sqlite::eab::Eab;
12use crate::webadmin::AdminState;
13use crate::webadmin::error::AdminError;
14use crate::webadmin::handlers::paging::{PageParams, page_envelope};
15use crate::webadmin::handlers::params::empty_is_absent;
16use crate::webadmin::session::{Authenticated, AuthenticatedWrite};
17
18#[derive(Debug, Deserialize, Default)]
19pub struct CreateEab {
20    #[serde(default, deserialize_with = "empty_is_absent")]
21    pub label: Option<String>,
22    /// Bind the credential to one endpoint. Absent means every profile.
23    #[serde(default, deserialize_with = "empty_is_absent")]
24    pub profile: Option<String>,
25}
26
27/// `GET /api/eab?limit=&offset=` — one page of credentials. Never the secret.
28///
29/// Takes `Query<PageParams>` directly rather than declaring the window inline:
30/// the `#[serde(flatten)]` trap documented on `AccountListParams` needs a
31/// filter to flatten *around*, and this listing has none. `oldest first` here,
32/// where the other lists are newest first — see [`Eab::search`].
33pub async fn list_eab(
34    State(state): State<AdminState>,
35    Query(params): Query<PageParams>,
36    _auth: Authenticated,
37) -> Result<Json<Value>, AdminError> {
38    let page = params.resolve(&state.config);
39    let (keys, total) = Eab::search(page.limit, page.offset, &state.database).await?;
40    let items: Vec<Value> = keys.iter().map(admin::render_eab_json).collect();
41    Ok(Json(page_envelope(items, total, page)))
42}
43
44/// `GET /api/eab/{kid}` — one credential. Never the secret.
45pub async fn get_eab(
46    State(state): State<AdminState>,
47    Path(kid): Path<String>,
48    _auth: Authenticated,
49) -> Result<Json<Value>, AdminError> {
50    let eab = Eab::find_any_by_kid(&kid, &state.database)
51        .await?
52        .ok_or_else(|| not_found(&kid))?;
53    Ok(Json(admin::render_eab_json(&eab)))
54}
55
56/// `POST /api/eab` — mint a credential.
57///
58/// **The only response in this API that carries a secret.** It is shown once
59/// and is not recoverable afterwards, exactly as `acme-proxy eab create`
60/// behaves — a lost credential is replaced, never read back. The log records
61/// the kid and never the secret.
62pub async fn create_eab(
63    State(state): State<AdminState>,
64    AuthenticatedWrite(auth): AuthenticatedWrite,
65    body: Option<Json<CreateEab>>,
66) -> Result<Response, AdminError> {
67    let Json(body) = body.unwrap_or_default();
68
69    require_mounted_profile(&state, body.profile.as_deref(), "omit `profile`")?;
70
71    let eab = Eab::create(body.label, body.profile, &state.database).await?;
72    tracing::info!(event = "admin_eab_created",
73                   outcome = "success",
74                   surface = "api",
75                   kid = %eab.kid,
76                   profile = ?eab.profile,
77                   username = %auth.user.username);
78
79    Ok((
80        StatusCode::CREATED,
81        Json(admin::render_eab_created_json(&eab)),
82    )
83        .into_response())
84}
85
86/// `POST /api/eab/{kid}/revoke`
87///
88/// Spelled `revoke` rather than `DELETE`, because the row survives: the model
89/// moves it to `revoked` and the CLI calls it the same thing. Accounts already
90/// bound under it are deliberately unaffected.
91pub async fn revoke_eab(
92    State(state): State<AdminState>,
93    Path(kid): Path<String>,
94    AuthenticatedWrite(auth): AuthenticatedWrite,
95) -> Result<StatusCode, AdminError> {
96    if !Eab::revoke(&kid, &state.database).await? {
97        return Err(not_found(&kid));
98    }
99    tracing::info!(event = "admin_eab_revoked", outcome = "success", surface = "api", kid = %kid, username = %auth.user.username);
100    Ok(StatusCode::NO_CONTENT)
101}
102
103/// Refuses a credential scoped to an endpoint this process does not serve.
104///
105/// Shared by both front ends, because the *condition* is one rule and two
106/// copies of it drift: such a credential would be accepted and then never be
107/// usable, which is worth catching while the operator is still looking at what
108/// they typed. `hint` is the one part that is legitimately per-front-end — a
109/// JSON caller omits a field, someone at a form leaves an input blank.
110pub(crate) fn require_mounted_profile(
111    state: &AdminState,
112    profile: Option<&str>,
113    hint: &str,
114) -> Result<(), AdminError> {
115    if let Some(name) = profile
116        && !state.profiles.contains_key(name)
117    {
118        return Err(AdminError::bad_request(format!(
119            "no profile named `{name}` is mounted; {hint} for a credential valid at \
120             every endpoint"
121        )));
122    }
123    Ok(())
124}
125
126fn not_found(kid: &str) -> AdminError {
127    AdminError::not_found(format!("no such EAB credential: {kid}"))
128}