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, Query, 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::handlers::paging::{Page, PageParams};
19use crate::webadmin::handlers::params::non_empty;
20use crate::webadmin::pages::auth::{PageSession, PageSessionWrite};
21use crate::webadmin::pages::error::PageError;
22use crate::webadmin::pages::{chrome, flash, page_value, pager, respond, respond_fragment};
23
24#[derive(Debug, Deserialize, Default)]
25pub struct CreateForm {
26    /// A human label, free text. Rendered into the list and the detail, which
27    /// is why every page template is `.html` and auto-escaped.
28    #[serde(default)]
29    pub label: String,
30    /// Empty means the credential is valid at every endpoint — the `NULL` the
31    /// column stores, not a profile named "".
32    #[serde(default)]
33    pub profile: String,
34}
35
36/// `GET /ui/eab?limit=&offset=`
37///
38/// Paged over `Eab::search`, the same query `GET /api/eab` and `eab list` read
39/// -- one listing, three surfaces, so none of them can come to describe the
40/// credential set differently. It was unpaged over an `Eab::list_all` that no
41/// longer exists, on the argument that an operator mints these by hand a few at
42/// a time; that is true of how the table fills and says nothing about how long
43/// it has been filling.
44pub async fn list_eab(
45    State(state): State<AdminState>,
46    Query(params): Query<PageParams>,
47    session: PageSession,
48) -> Result<Html<String>, PageError> {
49    let page = params.resolve(&state.config);
50    let (items, total) = rows(page, &state).await?;
51
52    let mut context = chrome(&session, "eab", "External Account Binding");
53    context.insert("page".to_string(), page_value(items, total));
54    context.insert(
55        "pager".to_string(),
56        pager(page, total, "/ui/eab", &[], "#eab-table"),
57    );
58    context.insert(
59        "profiles".to_string(),
60        Value::Array(crate::webadmin::handlers::misc::profile_rows(&state)),
61    );
62
63    respond(
64        &state,
65        session.hx,
66        "eab/list.html",
67        "eab/_table.html",
68        context,
69    )
70}
71
72/// `GET /ui/eab/{kid}`
73pub async fn get_eab(
74    State(state): State<AdminState>,
75    Path(kid): Path<String>,
76    session: PageSession,
77) -> Result<Html<String>, PageError> {
78    let eab = load(&kid, &state).await?;
79
80    let mut context = chrome(&session, "eab", "Credential");
81    context.insert("eab".to_string(), eab);
82
83    respond(
84        &state,
85        session.hx,
86        "eab/detail.html",
87        "eab/_card.html",
88        context,
89    )
90}
91
92/// `POST /ui/eab`
93///
94/// Answers `201` with the one-time secret, and refreshes the list underneath
95/// out of band — the new row would otherwise only appear on a reload, which is
96/// exactly when the secret would be gone.
97pub async fn create_eab(
98    State(state): State<AdminState>,
99    session: PageSessionWrite,
100    // See `pages::orders::revoke_order`: `Option<Form<_>>` is not an axum
101    // extractor, and this is only ever reached from a browser form.
102    axum::Form(form): axum::Form<CreateForm>,
103) -> Result<Response, PageError> {
104    let label = non_empty(&form.label);
105    let profile = non_empty(&form.profile);
106
107    super::super::handlers::eab::require_mounted_profile(
108        &state,
109        profile.as_deref(),
110        "leave it unset",
111    )?;
112
113    let eab = Eab::create(label, profile, &state.database).await?;
114    tracing::info!(event = "admin_eab_created",
115                   outcome = "success",
116                   surface = "ui",
117                   kid = %eab.kid,
118                   username = %session.auth.user.username);
119
120    // The **first** page, whatever page the form was posted from, and the
121    // reason `Eab::search` is newest first: a credential minted a moment ago is
122    // its first row, so the refreshed table below the form is guaranteed to
123    // contain the row the secret above it belongs to. Re-rendering the
124    // operator's current page instead would show the new credential only when
125    // they happened to be on the right one.
126    let page = PageParams::default().resolve(&state.config);
127    let (items, total) = rows(page, &state).await?;
128
129    let mut context = Map::new();
130    context.insert("eab".to_string(), admin::render_eab_created_json(&eab));
131    context.insert("page".to_string(), page_value(items, total));
132    context.insert(
133        "pager".to_string(),
134        pager(page, total, "/ui/eab", &[], "#eab-table"),
135    );
136    // Read by `eab/_table.html`'s root element: this response carries the table
137    // as well as the new credential, and htmx matches an out-of-band swap on
138    // the id of the element carrying the attribute.
139    context.insert("oob".to_string(), Value::Bool(true));
140
141    let body = respond_fragment(&state, "eab/_created.html", context)?;
142    Ok((StatusCode::CREATED, body).into_response())
143}
144
145/// `POST /ui/eab/{kid}/revoke`
146pub async fn revoke_eab(
147    State(state): State<AdminState>,
148    Path(kid): Path<String>,
149    session: PageSessionWrite,
150) -> Result<Html<String>, PageError> {
151    // Idempotent, so a second revoke is not an error — but the row still has to
152    // exist, or the operator is being told something happened to nothing.
153    if !Eab::revoke(&kid, &state.database).await? {
154        return Err(not_found(&kid));
155    }
156
157    tracing::info!(event = "admin_eab_revoked",
158                   outcome = "success",
159                   surface = "ui",
160                   kid = %kid,
161                   username = %session.auth.user.username);
162
163    let eab = load(&kid, &state).await?;
164    let mut context = Map::new();
165    context.insert(
166        "csrf_token".to_string(),
167        Value::String(session.auth.session.csrf_token.clone()),
168    );
169    context.insert("eab".to_string(), eab);
170    context.insert(
171        "flash".to_string(),
172        flash(
173            "ok",
174            "Credential revoked. Registrations using it fail from now on.",
175        ),
176    );
177    respond_fragment(&state, "eab/_card.html", context)
178}
179
180async fn rows(page: Page, state: &AdminState) -> Result<(Vec<Value>, i64), PageError> {
181    let (keys, total) = Eab::search(page.limit, page.offset, &state.database).await?;
182    Ok((keys.iter().map(admin::render_eab_json).collect(), total))
183}
184
185async fn load(kid: &str, state: &AdminState) -> Result<Value, PageError> {
186    let eab = Eab::find_any_by_kid(kid, &state.database)
187        .await?
188        .ok_or_else(|| not_found(kid))?;
189    Ok(admin::render_eab_json(&eab))
190}
191
192fn not_found(kid: &str) -> PageError {
193    PageError::not_found(format!("no such EAB credential: {kid}"))
194}