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