Skip to main content

acme_proxy_admin/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::webadmin::AdminState;
17use crate::webadmin::error::AdminError;
18use crate::webadmin::handlers::Caller;
19use crate::webadmin::handlers::eab::{
20    DeleteEabParams, apply_create_eab, apply_delete_eab, apply_revoke_eab,
21};
22use crate::webadmin::handlers::paging::{Page, PageParams};
23use crate::webadmin::handlers::params::non_empty;
24use crate::webadmin::pages::auth::{PageSession, PageSessionWrite};
25use crate::webadmin::pages::error::{PageError, redirect};
26use crate::webadmin::pages::{chrome, flash, page_value, pager, respond, respond_fragment};
27use acme_proxy_store::account::Account;
28use acme_proxy_store::eab::Eab;
29
30/// The form of `POST /ui/eab`. A form has no absent field, so an empty string
31/// stands for one.
32#[derive(Debug, Deserialize, Default)]
33pub struct CreateForm {
34    /// A human label, free text. Rendered into the list and the detail, which
35    /// is why every page template is `.html` and auto-escaped.
36    #[serde(default)]
37    pub label: String,
38    /// Empty means the credential is valid at every endpoint — the `NULL` the
39    /// column stores, not a profile named "".
40    #[serde(default)]
41    pub profile: String,
42}
43
44/// `GET /ui/eab?limit=&offset=`
45///
46/// Paged over `Eab::search`, the same query `GET /api/eab` and `eab list` read
47/// -- one listing, three surfaces, so none of them can come to describe the
48/// credential set differently. It was unpaged over an `Eab::list_all` that no
49/// longer exists, on the argument that an operator mints these by hand a few at
50/// a time; that is true of how the table fills and says nothing about how long
51/// it has been filling.
52pub async fn list_eab(
53    State(state): State<AdminState>,
54    Query(params): Query<PageParams>,
55    session: PageSession,
56) -> Result<Html<String>, PageError> {
57    let page = params.resolve(&state.config);
58    let (items, total) = rows(page, &state).await?;
59
60    let mut context = chrome(&session, "eab", "External Account Binding");
61    context.insert("page".to_string(), page_value(items, total));
62    context.insert(
63        "pager".to_string(),
64        pager(page, total, "/ui/eab", &[], "#eab-table"),
65    );
66    context.insert(
67        "profiles".to_string(),
68        Value::Array(crate::webadmin::handlers::misc::profile_rows(&state)),
69    );
70
71    respond(
72        &state,
73        session.hx,
74        "eab/list.html",
75        "eab/_table.html",
76        context,
77    )
78}
79
80/// `GET /ui/eab/{kid}`
81pub async fn get_eab(
82    State(state): State<AdminState>,
83    Path(kid): Path<String>,
84    session: PageSession,
85) -> Result<Html<String>, PageError> {
86    let eab = load(&kid, &state).await?;
87
88    let mut context = chrome(&session, "eab", "Credential");
89    context.insert("eab".to_string(), eab);
90    insert_bound_accounts(&mut context, &kid, &state).await?;
91
92    respond(
93        &state,
94        session.hx,
95        "eab/detail.html",
96        "eab/_card.html",
97        context,
98    )
99}
100
101/// `POST /ui/eab`
102///
103/// Answers `201` with the one-time secret, and refreshes the list underneath
104/// out of band — the new row would otherwise only appear on a reload, which is
105/// exactly when the secret would be gone.
106pub async fn create_eab(
107    State(state): State<AdminState>,
108    session: PageSessionWrite,
109    request_context: acme_proxy_core::audit::RequestContext,
110    // See `pages::orders::revoke_order`: `Option<Form<_>>` is not an axum
111    // extractor, and this is only ever reached from a browser form.
112    axum::Form(form): axum::Form<CreateForm>,
113) -> Result<Response, PageError> {
114    let eab = apply_create_eab(
115        &state,
116        &Caller::ui(&session.auth, &request_context),
117        non_empty(&form.label),
118        non_empty(&form.profile),
119        "leave it unset",
120    )
121    .await?;
122
123    // The **first** page, whatever page the form was posted from, and the
124    // reason `Eab::search` is newest first: a credential minted a moment ago is
125    // its first row, so the refreshed table below the form is guaranteed to
126    // contain the row the secret above it belongs to. Re-rendering the
127    // operator's current page instead would show the new credential only when
128    // they happened to be on the right one.
129    let page = PageParams::default().resolve(&state.config);
130    let (items, total) = rows(page, &state).await?;
131
132    let mut context = Map::new();
133    context.insert("eab".to_string(), admin::render_eab_created_json(&eab));
134    context.insert("page".to_string(), page_value(items, total));
135    context.insert(
136        "pager".to_string(),
137        pager(page, total, "/ui/eab", &[], "#eab-table"),
138    );
139    // Read by `eab/_table.html`'s root element: this response carries the table
140    // as well as the new credential, and htmx matches an out-of-band swap on
141    // the id of the element carrying the attribute.
142    context.insert("oob".to_string(), Value::Bool(true));
143
144    let body = respond_fragment(&state, "eab/_created.html", context)?;
145    Ok((StatusCode::CREATED, body).into_response())
146}
147
148/// `POST /ui/eab/{kid}/revoke`
149pub async fn revoke_eab(
150    State(state): State<AdminState>,
151    Path(kid): Path<String>,
152    session: PageSessionWrite,
153    request_context: acme_proxy_core::audit::RequestContext,
154) -> Result<Html<String>, PageError> {
155    apply_revoke_eab(&state, &Caller::ui(&session.auth, &request_context), &kid).await?;
156
157    let mut context = card_context(&kid, &state, &session).await?;
158    context.insert(
159        "flash".to_string(),
160        flash(
161            "ok",
162            "Credential revoked. Registrations using it fail from now on.",
163        ),
164    );
165    respond_fragment(&state, "eab/_card.html", context)
166}
167
168/// `DELETE /ui/eab/{kid}?accounts=keep|deactivate|delete`
169///
170/// Answers with a redirect to the list, the page the button lives on being the
171/// thing that just stopped existing. A refusal — an unknown mode, or live
172/// certificates under `accounts=delete` — is the card with the reason beside
173/// the buttons, and nothing changed.
174pub async fn delete_eab(
175    State(state): State<AdminState>,
176    Path(kid): Path<String>,
177    Query(params): Query<DeleteEabParams>,
178    session: PageSessionWrite,
179    request_context: acme_proxy_core::audit::RequestContext,
180) -> Result<Response, PageError> {
181    let accounts = match params.resolve() {
182        Ok(accounts) => accounts,
183        Err(error) => return refuse(&kid, &state, &session, &error).await,
184    };
185    let caller = Caller::ui(&session.auth, &request_context);
186    match apply_delete_eab(&state, &caller, &kid, accounts).await {
187        Ok(_) => {}
188        // Nothing to show a card for, or not about this credential at all.
189        Err(error) if error.status == StatusCode::NOT_FOUND || error.status.is_server_error() => {
190            return Err(error.into());
191        }
192        Err(error) => return refuse(&kid, &state, &session, &error).await,
193    }
194
195    Ok(redirect("/ui/eab", session.hx))
196}
197
198/// The card with `error` as its banner, keeping the error's status.
199async fn refuse(
200    kid: &str,
201    state: &AdminState,
202    session: &PageSessionWrite,
203    error: &AdminError,
204) -> Result<Response, PageError> {
205    let context = card_context(kid, state, session).await?;
206    super::refuse_with_card(state, "eab/_card.html", context, error)
207}
208
209/// The card's context as a mutation re-renders it: the credential re-read, and
210/// what it bound.
211async fn card_context(
212    kid: &str,
213    state: &AdminState,
214    session: &PageSessionWrite,
215) -> Result<Map<String, Value>, PageError> {
216    let mut context = super::fragment_context(&session.auth);
217    context.insert("eab".to_string(), load(kid, state).await?);
218    insert_bound_accounts(&mut context, kid, state).await?;
219    Ok(context)
220}
221
222/// `bound`: the counts the card's accounts link and its delete buttons read,
223/// from the same `Account::eab_summary` `eab delete` words its prompt with.
224async fn insert_bound_accounts(
225    context: &mut Map<String, Value>,
226    kid: &str,
227    state: &AdminState,
228) -> Result<(), PageError> {
229    let summary = match acme_proxy_store::id::parse(kid) {
230        Some(kid) => Account::eab_summary(kid, &state.database).await?,
231        None => Default::default(),
232    };
233    context.insert(
234        "bound".to_string(),
235        serde_json::json!({
236            "accounts": summary.accounts,
237            "activeAccounts": summary.active_accounts,
238            "orders": summary.orders,
239            "liveCertificates": summary.live_certificates,
240        }),
241    );
242    Ok(())
243}
244
245async fn rows(page: Page, state: &AdminState) -> Result<(Vec<Value>, i64), PageError> {
246    let (keys, total) = Eab::search(page.limit, page.offset, &state.database).await?;
247    Ok((keys.iter().map(admin::render_eab_json).collect(), total))
248}
249
250async fn load(kid: &str, state: &AdminState) -> Result<Value, PageError> {
251    let eab = Eab::find_any_by_kid(kid, &state.database)
252        .await?
253        .ok_or_else(|| not_found(kid))?;
254    Ok(admin::render_eab_json(&eab))
255}
256
257fn not_found(kid: &str) -> PageError {
258    PageError::not_found(crate::admin::subject::Subject::EabCredential.missing(kid))
259}