Skip to main content

acme_proxy_admin/webadmin/pages/
operators.rs

1//! `/ui/operators` — every operator this process has, and acting on one
2//! *other* than the caller.
3//!
4//! [`crate::webadmin::handlers::operators`]'s page twin, the `handlers`/`pages`
5//! split every other resource in this tree follows: both call the same
6//! `crates/admin/src/admin/` operations, this one renders HTML. Managing *yourself* stays on
7//! `/ui/account`, which is why `GET /ui/operators/{username}` redirects there
8//! the moment `username` resolves to the caller rather than rendering a
9//! half-disabled copy of this page's own template.
10
11use axum::extract::{Path, Query, State};
12use axum::response::{Html, IntoResponse, Response};
13use serde::Deserialize;
14use serde_json::{Map, Value};
15
16use crate::admin;
17use crate::admin::{mfa, users};
18use crate::webadmin::AdminState;
19use crate::webadmin::error::AdminError;
20use crate::webadmin::handlers::mfa::verify_current_password;
21use crate::webadmin::handlers::operators::{
22    OperatorAction, apply_operator_action, find, refuse_self_target,
23};
24use crate::webadmin::handlers::paging::{Page, PageParams};
25use crate::webadmin::pages::auth::{PageAdminRead, PageAdminWrite};
26use crate::webadmin::pages::error::{PageError, redirect};
27use crate::webadmin::pages::{chrome, flash, page_value, pager, respond, respond_fragment};
28use crate::webadmin::session::AdminClientIp;
29use acme_proxy_store::admin_session::AdminSession;
30use acme_proxy_store::admin_user::AdminRole;
31use acme_proxy_store::admin_user::AdminUser;
32
33/// The `/ui` twin of [`crate::webadmin::handlers::mfa::StepUpRequest`] — the
34/// password a form field collects, pulled in by `hx-include`, the
35/// `account::StepUpForm` precedent.
36#[derive(Debug, Default, Deserialize)]
37pub struct StepUpForm {
38    #[serde(default)]
39    pub password: String,
40}
41
42/// `GET /ui/operators?limit=&offset=`
43pub async fn list_operators(
44    State(state): State<AdminState>,
45    Query(params): Query<PageParams>,
46    session: PageAdminRead,
47) -> Result<Html<String>, PageError> {
48    let page = params.resolve(&state.config);
49    let (operators, total) = rows(page, &state).await?;
50
51    let mut context = chrome(&session, "operators", "Operators");
52    context.insert("page".to_string(), page_value(operators, total));
53    context.insert(
54        "pager".to_string(),
55        pager(page, total, "/ui/operators", &[], "#operators-table"),
56    );
57
58    respond(
59        &state,
60        session.hx,
61        "operators/list.html",
62        "operators/_table.html",
63        context,
64    )
65}
66
67/// `GET /ui/operators/{username}` — redirects to `/ui/account` when `username`
68/// is the caller.
69pub async fn get_operator(
70    State(state): State<AdminState>,
71    Path(username): Path<String>,
72    session: PageAdminRead,
73) -> Result<Response, PageError> {
74    let target = find(&username, &state).await?;
75    if target.id == session.auth.user.id {
76        return Ok(redirect("/ui/account", session.hx));
77    }
78
79    let mut context = chrome(&session, "operators", "Operator");
80    for (key, value) in detail_context(&state, &target).await? {
81        context.insert(key, value);
82    }
83
84    Ok(respond(
85        &state,
86        session.hx,
87        "operators/detail.html",
88        "operators/_card.html",
89        context,
90    )?
91    .into_response())
92}
93/// `POST /ui/operators/{username}/disable`
94pub async fn disable_operator(
95    State(state): State<AdminState>,
96    Path(username): Path<String>,
97    AdminClientIp(client): AdminClientIp,
98    session: PageAdminWrite,
99    request_context: acme_proxy_core::audit::RequestContext,
100    axum::Form(body): axum::Form<StepUpForm>,
101) -> Result<Response, PageError> {
102    act(
103        &state,
104        &session,
105        &username,
106        &body.password,
107        client,
108        &request_context,
109        OperatorAction::SetStatus { active: false },
110        flash("ok", "Operator disabled. Their sessions were revoked."),
111    )
112    .await
113}
114
115/// `POST /ui/operators/{username}/enable`
116pub async fn enable_operator(
117    State(state): State<AdminState>,
118    Path(username): Path<String>,
119    AdminClientIp(client): AdminClientIp,
120    session: PageAdminWrite,
121    request_context: acme_proxy_core::audit::RequestContext,
122    axum::Form(body): axum::Form<StepUpForm>,
123) -> Result<Response, PageError> {
124    act(
125        &state,
126        &session,
127        &username,
128        &body.password,
129        client,
130        &request_context,
131        OperatorAction::SetStatus { active: true },
132        flash("ok", "Operator enabled."),
133    )
134    .await
135}
136
137/// `POST /ui/operators/{username}/totp/reset`
138pub async fn reset_operator_totp(
139    State(state): State<AdminState>,
140    Path(username): Path<String>,
141    AdminClientIp(client): AdminClientIp,
142    session: PageAdminWrite,
143    request_context: acme_proxy_core::audit::RequestContext,
144    axum::Form(body): axum::Form<StepUpForm>,
145) -> Result<Response, PageError> {
146    act(
147        &state,
148        &session,
149        &username,
150        &body.password,
151        client,
152        &request_context,
153        OperatorAction::ResetTotp,
154        flash(
155            "warn",
156            "Their second factor and recovery codes were removed. They can \
157             sign in with a password alone until they enrol again.",
158        ),
159    )
160    .await
161}
162
163/// `POST /ui/operators/{username}/sessions/{id}/revoke`
164pub async fn revoke_operator_session(
165    State(state): State<AdminState>,
166    Path((username, id)): Path<(String, String)>,
167    AdminClientIp(client): AdminClientIp,
168    session: PageAdminWrite,
169    request_context: acme_proxy_core::audit::RequestContext,
170    axum::Form(body): axum::Form<StepUpForm>,
171) -> Result<Response, PageError> {
172    act(
173        &state,
174        &session,
175        &username,
176        &body.password,
177        client,
178        &request_context,
179        OperatorAction::RevokeSession { fingerprint: &id },
180        flash("ok", "Session revoked."),
181    )
182    .await
183}
184
185/// The contact form on the operator card: the address, plus the step-up
186/// password `hx-include` pulls in. A blank address clears it.
187#[derive(Debug, Default, Deserialize)]
188pub struct OperatorContactForm {
189    #[serde(default)]
190    pub password: String,
191    #[serde(default)]
192    pub contact: String,
193}
194
195/// The role form on the operator card.
196#[derive(Debug, Default, Deserialize)]
197pub struct OperatorRoleForm {
198    #[serde(default)]
199    pub password: String,
200    #[serde(default)]
201    pub role: String,
202}
203
204/// `POST /ui/operators/{username}/contact`
205pub async fn set_operator_contact(
206    State(state): State<AdminState>,
207    Path(username): Path<String>,
208    AdminClientIp(client): AdminClientIp,
209    session: PageAdminWrite,
210    request_context: acme_proxy_core::audit::RequestContext,
211    axum::Form(form): axum::Form<OperatorContactForm>,
212) -> Result<Response, PageError> {
213    let banner = if form.contact.trim().is_empty() {
214        flash(
215            "warn",
216            "Their notification address was cleared: security notifications cannot reach them.",
217        )
218    } else {
219        flash(
220            "ok",
221            "Their notification address was changed. The previous one was told.",
222        )
223    };
224    act(
225        &state,
226        &session,
227        &username,
228        &form.password,
229        client,
230        &request_context,
231        OperatorAction::SetContact {
232            contact: Some(form.contact.as_str()),
233        },
234        banner,
235    )
236    .await
237}
238
239/// `POST /ui/operators/{username}/role`
240pub async fn set_operator_role(
241    State(state): State<AdminState>,
242    Path(username): Path<String>,
243    AdminClientIp(client): AdminClientIp,
244    session: PageAdminWrite,
245    request_context: acme_proxy_core::audit::RequestContext,
246    axum::Form(form): axum::Form<OperatorRoleForm>,
247) -> Result<Response, PageError> {
248    let role = match form.role.parse::<AdminRole>() {
249        Ok(role) => role,
250        // A value outside the select is a banner beside it, like every other
251        // refusal on this card -- once the target and the self-target rule
252        // have had their say, so a typo is not how an operator learns who
253        // exists.
254        Err(message) => {
255            let target = find(&username, &state).await?;
256            refuse_self_target(&session.auth.user, &target)?;
257            let context = detail_context(&state, &target).await?;
258            return super::refuse_with_card(
259                &state,
260                "operators/_card.html",
261                context,
262                &AdminError::bad_request(message),
263            );
264        }
265    };
266    act(
267        &state,
268        &session,
269        &username,
270        &form.password,
271        client,
272        &request_context,
273        OperatorAction::SetRole { role },
274        flash(
275            "ok",
276            format!(
277                "Role changed to {}. Their sessions were revoked.",
278                role.as_str()
279            ),
280        ),
281    )
282    .await
283}
284
285/// The `/ui` spelling of the shared sequence — the twin of
286/// [`crate::webadmin::handlers::operators`]'s own `act`.
287///
288/// Identical up to two things, which is the whole of what separates the two
289/// front ends here: the password refusal is rendered as the operator card's own
290/// banner rather than returned as an error document, and success re-renders
291/// that card instead of answering `204`.
292#[allow(clippy::too_many_arguments)]
293async fn act(
294    state: &AdminState,
295    session: &PageAdminWrite,
296    username: &str,
297    password: &str,
298    client: Option<std::net::IpAddr>,
299    request_context: &acme_proxy_core::audit::RequestContext,
300    action: OperatorAction<'_>,
301    banner: Value,
302) -> Result<Response, PageError> {
303    let mut target = find(username, state).await?;
304    refuse_self_target(&session.auth.user, &target)?;
305    if let Some(refusal) =
306        refuse_without_password(state, session, &target, password, client).await?
307    {
308        return Ok(refusal);
309    }
310
311    if let Err(error) = apply_operator_action(
312        state,
313        &session.auth.user,
314        &mut target,
315        action,
316        client,
317        request_context,
318        "ui",
319    )
320    .await
321    {
322        // "The row's state is a banner, the server's problem is a page": a
323        // refused value (an address that is not a mailbox) belongs beside the
324        // control it came from. A `404` still replaces the page, since the
325        // operator or session it named is gone.
326        if error.status.is_client_error() && error.status != axum::http::StatusCode::NOT_FOUND {
327            let context = detail_context(state, &target).await?;
328            return super::refuse_with_card(state, "operators/_card.html", context, &error);
329        }
330        return Err(error.into());
331    }
332
333    respond_card(state, &target, banner).await
334}
335
336/// [`verify_current_password`] with the refusal rendered as the operator card's
337/// own banner — the `account::refuse_without_password` shape: the session is
338/// live and the page is the right page, only this one action was refused.
339///
340/// `verify_current_password`, not `check_step_up`: see
341/// [`crate::webadmin::handlers::operators`]'s module doc for why this surface
342/// asks even of a caller who has enrolled no second factor.
343async fn refuse_without_password(
344    state: &AdminState,
345    session: &PageAdminWrite,
346    target: &AdminUser,
347    password: &str,
348    client: Option<std::net::IpAddr>,
349) -> Result<Option<Response>, PageError> {
350    let Err(error) =
351        verify_current_password(&session.auth.user, password, client, &state.logins).await
352    else {
353        return Ok(None);
354    };
355    let context = detail_context(state, target).await?;
356    Ok(Some(super::refuse_with_card(
357        state,
358        "operators/_card.html",
359        context,
360        &error,
361    )?))
362}
363
364/// Re-renders the operator card after a successful mutation.
365///
366/// Renders from the `target` [`apply_operator_action`] updated in place rather
367/// than re-reading the row: the two would agree, and the extra read is a second
368/// answer waiting to disagree. The sessions table inside it *is* re-read, since
369/// a disable or a revoke is exactly what changed it.
370async fn respond_card(
371    state: &AdminState,
372    target: &AdminUser,
373    banner: Value,
374) -> Result<Response, PageError> {
375    let mut context = detail_context(state, target).await?;
376    context.insert("flash".to_string(), banner);
377    Ok(respond_fragment(state, "operators/_card.html", context)?.into_response())
378}
379
380async fn rows(page: Page, state: &AdminState) -> Result<(Vec<Value>, i64), PageError> {
381    let (operators, total) =
382        users::list_users(page.limit, page.offset, state.database.clone()).await?;
383    Ok((
384        operators
385            .iter()
386            .map(admin::render_admin_user_json)
387            .collect(),
388        total,
389    ))
390}
391
392/// Everything `operators/_card.html` reads, minus `csrf_token` and `flash` --
393/// both are per-call (the token because a fragment rendered standalone cannot
394/// inherit `<body>`'s `hx-headers`, the banner because it differs by action).
395async fn detail_context(
396    state: &AdminState,
397    target: &AdminUser,
398) -> Result<Map<String, Value>, PageError> {
399    let remaining = mfa::recovery_codes_remaining(target.id, state.database.clone()).await?;
400    let mut context = Map::new();
401    context.insert(
402        "operator".to_string(),
403        admin::render_admin_user_detail_json(target, remaining),
404    );
405    // From the enum `AdminRole::from_str` parses against, so the role select
406    // cannot offer a tier the handler would refuse, or miss one it accepts.
407    context.insert(
408        "roles".to_string(),
409        Value::Array(
410            AdminRole::ALL
411                .iter()
412                .map(|role| Value::from(role.as_str()))
413                .collect(),
414        ),
415    );
416    context.insert(
417        "sessions".to_string(),
418        Value::Array(operator_sessions(state, target.id).await?),
419    );
420    context.insert(
421        "sessions_revoke_prefix".to_string(),
422        Value::String(format!("/ui/operators/{}/sessions", target.username)),
423    );
424    context.insert(
425        "sessions_target".to_string(),
426        Value::String("#operator-detail".to_string()),
427    );
428    // Present, unlike the account page's own sessions card: every mutation on
429    // this surface -- including revoking one of *another* operator's sessions
430    // -- re-proves the caller's own password. `#operator-step-up-password` is
431    // the field `operators/_card.html` renders once, shared by every button.
432    context.insert(
433        "sessions_step_up".to_string(),
434        Value::String("#operator-step-up-password".to_string()),
435    );
436    Ok(context)
437}
438
439/// One operator's live sessions, newest first, unmarked -- see
440/// [`crate::webadmin::handlers::operators::list_operator_sessions`] for why
441/// there is no `current` member here.
442async fn operator_sessions(
443    state: &AdminState,
444    user_id: uuid::Uuid,
445) -> Result<Vec<Value>, PageError> {
446    let page = PageParams::default().resolve(&state.config);
447    let (sessions, _total) =
448        AdminSession::search(Some(user_id), page.limit, page.offset, &state.database).await?;
449    Ok(sessions
450        .iter()
451        .map(admin::render_admin_session_json)
452        .collect())
453}