Skip to main content

acme_proxy/webadmin/pages/
eab.rs

1//! `/ui/eab` — External Account Binding credentials.
2//!
3//! The one page in this tree that ever renders a secret, and it renders it
4//! exactly once: `render_eab_created_json` is the only renderer carrying
5//! `hmacKey`, the list and the detail read the same row through
6//! `render_eab_json`, and `Eab::to_json` has no such member. A lost credential
7//! is replaced, never recovered.
8
9use axum::extract::{Path, State};
10use axum::http::StatusCode;
11use axum::response::{Html, IntoResponse, Response};
12use serde::Deserialize;
13use serde_json::{Map, Value};
14
15use crate::admin;
16use crate::sqlite::eab::Eab;
17use crate::webadmin::AdminState;
18use crate::webadmin::pages::auth::{PageSession, PageSessionWrite};
19use crate::webadmin::pages::error::PageError;
20use crate::webadmin::pages::{chrome, flash, respond, respond_fragment};
21
22#[derive(Debug, Deserialize, Default)]
23pub struct CreateForm {
24    /// A human label, free text. Rendered into the list and the detail, which
25    /// is why every page template is `.html` and auto-escaped.
26    #[serde(default)]
27    pub label: String,
28    /// Empty means the credential is valid at every endpoint — the `NULL` the
29    /// column stores, not a profile named "".
30    #[serde(default)]
31    pub profile: String,
32}
33
34/// `GET /ui/eab`
35///
36/// Unpaginated, matching `GET /api/eab`: an operator mints these by hand, a few
37/// at a time, and `Eab::list_all` is the only listing the model offers.
38pub async fn list_eab(
39    State(state): State<AdminState>,
40    session: PageSession,
41) -> Result<Html<String>, PageError> {
42    let mut context = chrome(&session, "eab", "External Account Binding");
43    context.insert("items".to_string(), Value::Array(rows(&state).await?));
44    context.insert(
45        "profiles".to_string(),
46        Value::Array(crate::webadmin::handlers::misc::profile_rows(&state)),
47    );
48
49    respond(
50        &state,
51        session.hx,
52        "eab/list.html",
53        "eab/_table.html",
54        context,
55    )
56}
57
58/// `GET /ui/eab/{kid}`
59pub async fn get_eab(
60    State(state): State<AdminState>,
61    Path(kid): Path<String>,
62    session: PageSession,
63) -> Result<Html<String>, PageError> {
64    let eab = load(&kid, &state).await?;
65
66    let mut context = chrome(&session, "eab", "Credential");
67    context.insert("eab".to_string(), eab);
68
69    respond(
70        &state,
71        session.hx,
72        "eab/detail.html",
73        "eab/_card.html",
74        context,
75    )
76}
77
78/// `POST /ui/eab`
79///
80/// Answers `201` with the one-time secret, and refreshes the list underneath
81/// out of band — the new row would otherwise only appear on a reload, which is
82/// exactly when the secret would be gone.
83pub async fn create_eab(
84    State(state): State<AdminState>,
85    session: PageSessionWrite,
86    // See `pages::orders::revoke_order`: `Option<Form<_>>` is not an axum
87    // extractor, and this is only ever reached from a browser form.
88    axum::Form(form): axum::Form<CreateForm>,
89) -> Result<Response, PageError> {
90    let label = non_empty(&form.label);
91    let profile = non_empty(&form.profile);
92
93    super::super::handlers::eab::require_mounted_profile(
94        &state,
95        profile.as_deref(),
96        "leave it unset",
97    )?;
98
99    let eab = Eab::create(label, profile, &state.database).await?;
100    tracing::info!(event = "admin_eab_created",
101                   outcome = "success",
102                   surface = "ui",
103                   kid = %eab.kid,
104                   username = %session.auth.user.username);
105
106    let mut context = Map::new();
107    context.insert("eab".to_string(), admin::render_eab_created_json(&eab));
108    context.insert("items".to_string(), Value::Array(rows(&state).await?));
109    // Read by `eab/_table.html`'s root element: this response carries the table
110    // as well as the new credential, and htmx matches an out-of-band swap on
111    // the id of the element carrying the attribute.
112    context.insert("oob".to_string(), Value::Bool(true));
113
114    let body = respond_fragment(&state, "eab/_created.html", context)?;
115    Ok((StatusCode::CREATED, body).into_response())
116}
117
118/// `POST /ui/eab/{kid}/revoke`
119pub async fn revoke_eab(
120    State(state): State<AdminState>,
121    Path(kid): Path<String>,
122    session: PageSessionWrite,
123) -> Result<Html<String>, PageError> {
124    // Idempotent, so a second revoke is not an error — but the row still has to
125    // exist, or the operator is being told something happened to nothing.
126    if !Eab::revoke(&kid, &state.database).await? {
127        return Err(not_found(&kid));
128    }
129
130    tracing::info!(event = "admin_eab_revoked",
131                   outcome = "success",
132                   surface = "ui",
133                   kid = %kid,
134                   username = %session.auth.user.username);
135
136    let eab = load(&kid, &state).await?;
137    let mut context = Map::new();
138    context.insert(
139        "csrf_token".to_string(),
140        Value::String(session.auth.session.csrf_token.clone()),
141    );
142    context.insert("eab".to_string(), eab);
143    context.insert(
144        "flash".to_string(),
145        flash(
146            "ok",
147            "Credential revoked. Registrations using it fail from now on.",
148        ),
149    );
150    respond_fragment(&state, "eab/_card.html", context)
151}
152
153async fn rows(state: &AdminState) -> Result<Vec<Value>, PageError> {
154    Ok(Eab::list_all(&state.database)
155        .await?
156        .iter()
157        .map(admin::render_eab_json)
158        .collect())
159}
160
161async fn load(kid: &str, state: &AdminState) -> Result<Value, PageError> {
162    let eab = Eab::find_any_by_kid(kid, &state.database)
163        .await?
164        .ok_or_else(|| not_found(kid))?;
165    Ok(admin::render_eab_json(&eab))
166}
167
168/// A form field left blank is absent, not the empty string.
169fn non_empty(raw: &str) -> Option<String> {
170    let trimmed = raw.trim();
171    (!trimmed.is_empty()).then(|| trimmed.to_string())
172}
173
174fn not_found(kid: &str) -> PageError {
175    PageError::not_found(format!("no such EAB credential: {kid}"))
176}