Skip to main content

acme_proxy_admin/webadmin/handlers/
operators.rs

1//! `/api/operators` — every operator this process has, and acting on one
2//! *other* than the caller: disable, enable, reset their second factor, list
3//! and revoke their sessions.
4//!
5//! Distinct from `/api/account` (`handlers::account`), which is the same
6//! operator managing themselves. That split is the trust boundary this module
7//! exists to enforce: every mutating route here runs
8//! [`crate::webadmin::handlers::mfa::verify_current_password`], and every one
9//! refuses a `username` that resolves to the caller — self-management stays on
10//! `/api/account`, which already owns it, and never needs a password re-typed
11//! to reach it.
12//!
13//! **`verify_current_password`, not `check_step_up`.** The latter passes
14//! unconditionally for an operator with no second factor, which is right where
15//! it was written — a first enrolment protects nothing, and a password there
16//! would stand in front of the `require_mfa` bootstrap. It is wrong here: this
17//! surface's blast radius is a *colleague's* account, which exists whether or
18//! not the caller has enrolled, so a password-only admin holding a stolen
19//! cookie could otherwise disable every other admin and wipe their factors
20//! without typing anything. `handlers::account::change_password` already made
21//! exactly this choice for its own ASVS V6.2.3 reason.
22//!
23//! The tail of every mutation is [`apply_operator_action`], shared with the
24//! `/ui` twin (`crate::webadmin::pages::operators`): it calls
25//! `crate::admin::changes`, which owns the write, the audit rows, the
26//! revoked-sessions row and the notification for the CLI as well, and adds
27//! the web surface's log line. Only the extractors, the `surface` field and
28//! the response shape differ between `/api` and `/ui`.
29//!
30//! `create`/`passwd` are deliberately absent, on both this surface and the
31//! page it backs: those mint a credential, which is where "no sign-up page"
32//! already draws the line — see `acme-proxy admin user create`/`passwd` on the
33//! host.
34
35use axum::Json;
36use axum::extract::{Path, Query, State};
37use axum::http::StatusCode;
38use axum::response::{IntoResponse, Response};
39use serde::Deserialize;
40use serde_json::Value;
41
42use crate::admin;
43use crate::admin::users::UserError;
44use crate::admin::{changes, mfa, users};
45use crate::webadmin::error::AdminError;
46use crate::webadmin::handlers::mfa::{StepUpRequest, verify_current_password};
47use crate::webadmin::handlers::paging::{PageParams, page_envelope};
48use crate::webadmin::session::{AdminClientIp, AdminRead, AdminWrite};
49use crate::webadmin::{AdminState, WebTrail};
50use acme_proxy_store::admin_session::AdminSession;
51use acme_proxy_store::admin_user::AdminRole;
52use acme_proxy_store::admin_user::AdminStatus;
53use acme_proxy_store::admin_user::AdminUser;
54
55/// `GET /api/operators?limit=&offset=` — every operator, oldest first.
56///
57/// The same [`AdminUser::search`] `admin user list` reads, so the panel and the
58/// terminal cannot come to describe the operator set differently.
59pub async fn list_operators(
60    State(state): State<AdminState>,
61    Query(params): Query<PageParams>,
62    _auth: AdminRead,
63) -> Result<Json<Value>, AdminError> {
64    let page = params.resolve(&state.config);
65    let (operators, total) = users::list_users(page.limit, page.offset, state.database).await?;
66    let items: Vec<Value> = operators
67        .iter()
68        .map(admin::render_admin_user_json)
69        .collect();
70    Ok(Json(page_envelope(items, total, page)))
71}
72
73/// `GET /api/operators/{username}` — one operator's detail, `admin user
74/// show`'s shape.
75pub async fn get_operator(
76    State(state): State<AdminState>,
77    Path(username): Path<String>,
78    _auth: AdminRead,
79) -> Result<Json<Value>, AdminError> {
80    let user = find(&username, &state).await?;
81    let remaining = mfa::recovery_codes_remaining(user.id, state.database.clone()).await?;
82    Ok(Json(admin::render_admin_user_detail_json(&user, remaining)))
83}
84
85/// `GET /api/operators/{username}/sessions?limit=&offset=` — one operator's
86/// live sessions, `admin session list --user`'s shape. No `current`
87/// marker: the caller viewing another operator's sessions has none of their
88/// own in this list, unlike `GET /api/account/sessions`.
89pub async fn list_operator_sessions(
90    State(state): State<AdminState>,
91    Path(username): Path<String>,
92    Query(params): Query<PageParams>,
93    _auth: AdminRead,
94) -> Result<Json<Value>, AdminError> {
95    let user = find(&username, &state).await?;
96    let page = params.resolve(&state.config);
97    let (sessions, total) =
98        AdminSession::search(Some(user.id), page.limit, page.offset, &state.database).await?;
99    let items: Vec<Value> = sessions
100        .iter()
101        .map(admin::render_admin_session_json)
102        .collect();
103    Ok(Json(page_envelope(items, total, page)))
104}
105/// `POST /api/operators/{username}/disable`
106pub async fn disable_operator(
107    State(state): State<AdminState>,
108    Path(username): Path<String>,
109    AdminClientIp(client): AdminClientIp,
110    AdminWrite(auth): AdminWrite,
111    request_context: acme_proxy_core::audit::RequestContext,
112    body: Option<Json<StepUpRequest>>,
113) -> Result<Response, AdminError> {
114    act(
115        &state,
116        &auth.user,
117        &username,
118        &body.unwrap_or_default().password,
119        client,
120        &request_context,
121        OperatorAction::SetStatus { active: false },
122    )
123    .await?;
124    Ok(StatusCode::NO_CONTENT.into_response())
125}
126
127/// `POST /api/operators/{username}/enable`
128pub async fn enable_operator(
129    State(state): State<AdminState>,
130    Path(username): Path<String>,
131    AdminClientIp(client): AdminClientIp,
132    AdminWrite(auth): AdminWrite,
133    request_context: acme_proxy_core::audit::RequestContext,
134    body: Option<Json<StepUpRequest>>,
135) -> Result<Response, AdminError> {
136    act(
137        &state,
138        &auth.user,
139        &username,
140        &body.unwrap_or_default().password,
141        client,
142        &request_context,
143        OperatorAction::SetStatus { active: true },
144    )
145    .await?;
146    Ok(StatusCode::NO_CONTENT.into_response())
147}
148
149/// `POST /api/operators/{username}/totp/reset` — the web twin of
150/// `acme-proxy admin user totp reset`: removes the factor, every recovery
151/// code, and every session the operator holds.
152pub async fn reset_operator_totp(
153    State(state): State<AdminState>,
154    Path(username): Path<String>,
155    AdminClientIp(client): AdminClientIp,
156    AdminWrite(auth): AdminWrite,
157    request_context: acme_proxy_core::audit::RequestContext,
158    body: Option<Json<StepUpRequest>>,
159) -> Result<Response, AdminError> {
160    act(
161        &state,
162        &auth.user,
163        &username,
164        &body.unwrap_or_default().password,
165        client,
166        &request_context,
167        OperatorAction::ResetTotp,
168    )
169    .await?;
170    Ok(StatusCode::NO_CONTENT.into_response())
171}
172
173/// `POST /api/operators/{username}/sessions/{id}/revoke`
174pub async fn revoke_operator_session(
175    State(state): State<AdminState>,
176    Path((username, id)): Path<(String, String)>,
177    AdminClientIp(client): AdminClientIp,
178    AdminWrite(auth): AdminWrite,
179    request_context: acme_proxy_core::audit::RequestContext,
180    body: Option<Json<StepUpRequest>>,
181) -> Result<Response, AdminError> {
182    act(
183        &state,
184        &auth.user,
185        &username,
186        &body.unwrap_or_default().password,
187        client,
188        &request_context,
189        OperatorAction::RevokeSession { fingerprint: &id },
190    )
191    .await?;
192    Ok(StatusCode::NO_CONTENT.into_response())
193}
194
195/// The body of `POST /api/operators/{username}/contact`: the step-up password
196/// and the address. An absent, `null` or blank `contact` clears it.
197#[derive(Debug, Default, Deserialize)]
198pub struct SetOperatorContactRequest {
199    #[serde(default)]
200    pub password: String,
201    #[serde(default)]
202    pub contact: Option<String>,
203}
204
205/// The body of `POST /api/operators/{username}/role`.
206#[derive(Debug, Default, Deserialize)]
207pub struct SetOperatorRoleRequest {
208    #[serde(default)]
209    pub password: String,
210    #[serde(default)]
211    pub role: String,
212}
213
214/// `POST /api/operators/{username}/contact` — the web twin of
215/// `acme-proxy admin user contact`. Tells the address it replaced.
216pub async fn set_operator_contact(
217    State(state): State<AdminState>,
218    Path(username): Path<String>,
219    AdminClientIp(client): AdminClientIp,
220    AdminWrite(auth): AdminWrite,
221    request_context: acme_proxy_core::audit::RequestContext,
222    body: Option<Json<SetOperatorContactRequest>>,
223) -> Result<Response, AdminError> {
224    let body = body.unwrap_or_default();
225    act(
226        &state,
227        &auth.user,
228        &username,
229        &body.password,
230        client,
231        &request_context,
232        OperatorAction::SetContact {
233            contact: body.contact.as_deref(),
234        },
235    )
236    .await?;
237    Ok(StatusCode::NO_CONTENT.into_response())
238}
239
240/// `POST /api/operators/{username}/role` — the web twin of
241/// `acme-proxy admin user role`: moves the operator to another tier and revokes
242/// every session they hold.
243///
244/// An unknown role is refused by name before anything else runs, `AdminRole`'s
245/// own rule. Demoting the last `admin` is refused by `users::set_role`, but it
246/// is not reachable from here: the caller is an `admin` and cannot target
247/// themselves, so an `admin` target always leaves at least one.
248pub async fn set_operator_role(
249    State(state): State<AdminState>,
250    Path(username): Path<String>,
251    AdminClientIp(client): AdminClientIp,
252    AdminWrite(auth): AdminWrite,
253    request_context: acme_proxy_core::audit::RequestContext,
254    body: Option<Json<SetOperatorRoleRequest>>,
255) -> Result<Response, AdminError> {
256    let body = body.unwrap_or_default();
257    let role: AdminRole = body.role.parse().map_err(AdminError::bad_request)?;
258    act(
259        &state,
260        &auth.user,
261        &username,
262        &body.password,
263        client,
264        &request_context,
265        OperatorAction::SetRole { role },
266    )
267    .await?;
268    Ok(StatusCode::NO_CONTENT.into_response())
269}
270
271/// The `/api` spelling of the shared sequence: resolve the target, refuse a
272/// self-target, re-prove the caller's own password, then
273/// [`apply_operator_action`].
274///
275/// The `/ui` twin runs the same four steps but renders the password refusal as
276/// the operator card's own banner, so it calls the pieces itself rather than
277/// this wrapper.
278async fn act(
279    state: &AdminState,
280    caller: &AdminUser,
281    username: &str,
282    password: &str,
283    client: Option<std::net::IpAddr>,
284    request_context: &acme_proxy_core::audit::RequestContext,
285    action: OperatorAction<'_>,
286) -> Result<(), AdminError> {
287    let mut target = find(username, state).await?;
288    refuse_self_target(caller, &target)?;
289    verify_current_password(caller, password, client, &state.logins).await?;
290    apply_operator_action(
291        state,
292        caller,
293        &mut target,
294        action,
295        client,
296        request_context,
297        "api",
298    )
299    .await
300}
301
302/// One colleague-management action, named once so the two front ends cannot
303/// come to disagree about what each of them does.
304#[derive(Debug)]
305pub(crate) enum OperatorAction<'a> {
306    /// Disable (`active: false`) or re-enable the operator. Disabling revokes
307    /// every session they hold, inside `users::set_status`.
308    SetStatus { active: bool },
309    /// Remove their second factor, every recovery code, and every session.
310    ResetTotp,
311    /// End one of their sessions, named by the fingerprint the listing prints.
312    RevokeSession { fingerprint: &'a str },
313    /// Set (`Some`) or clear (`None` or blank) the address their security
314    /// notifications go to.
315    SetContact { contact: Option<&'a str> },
316    /// Move them to another tier. Revokes every session they hold, inside
317    /// `users::set_role`.
318    SetRole { role: AdminRole },
319}
320
321/// Performs `action` through `crate::admin::changes`, which owns the write,
322/// the audit row(s) and — where a credential of the operator's changed — the
323/// notification to them, and adds the web surface's log line.
324///
325/// Shared by `/api` and `/ui`, as `changes` is shared with the CLI, so a row or
326/// a notification cannot be written on one surface and forgotten on another,
327/// which is what happened while each spelled this tail out for itself. The
328/// caller has already resolved `target`, refused a self-target and re-proved
329/// its own password; `surface` is the only thing it contributes here.
330///
331/// `target` is updated in place, so the page front end re-renders its card from
332/// it rather than reading the row back.
333pub(crate) async fn apply_operator_action(
334    state: &AdminState,
335    caller: &AdminUser,
336    target: &mut AdminUser,
337    action: OperatorAction<'_>,
338    client: Option<std::net::IpAddr>,
339    request_context: &acme_proxy_core::audit::RequestContext,
340    surface: &'static str,
341) -> Result<(), AdminError> {
342    let trail = WebTrail {
343        state,
344        request_context,
345        actor: &caller.username,
346        client,
347        by_self: caller.id == target.id,
348    };
349    match action {
350        OperatorAction::SetStatus { active } => {
351            let status = if active {
352                AdminStatus::Active
353            } else {
354                AdminStatus::Disabled
355            };
356            // The `Option` is the raced-delete answer: the row was found a
357            // moment ago and is gone now, so this reports "no such operator"
358            // rather than a `204` with no write behind it.
359            let (updated, revoked) =
360                changes::change_status(&target.username, status, state.database.clone(), &trail)
361                    .await?
362                    .ok_or_else(|| operator_not_found(&target.username))?;
363            *target = updated;
364            if active {
365                tracing::info!(event = "admin_operator_enabled",
366                               outcome = "success",
367                               surface = surface,
368                               username = %caller.username,
369                               target_username = %target.username);
370            } else {
371                tracing::info!(event = "admin_operator_disabled",
372                               outcome = "success",
373                               surface = surface,
374                               username = %caller.username,
375                               target_username = %target.username,
376                               sessions_revoked = revoked);
377            }
378        }
379        OperatorAction::ResetTotp => {
380            // A *different* operator's factor (`refuse_self_target` ran), so
381            // the trail's `by_self` is false — the same change `admin user
382            // totp reset` makes.
383            changes::reset_totp(target, state.database.clone(), &trail).await?;
384            tracing::info!(event = "admin_operator_totp_reset",
385                           outcome = "success",
386                           surface = surface,
387                           username = %caller.username,
388                           target_username = %target.username);
389        }
390        OperatorAction::RevokeSession { fingerprint } => {
391            let session =
392                AdminSession::find_by_user_and_fingerprint(target.id, fingerprint, &state.database)
393                    .await?
394                    .ok_or_else(|| session_not_found(fingerprint))?;
395            AdminSession::delete(&session.token_hash, &state.database).await?;
396            state
397                .record_admin_action(request_context, &caller.username, |actor, ctx| {
398                    acme_proxy_jobs::auditor::admin::session_revoked(
399                        actor,
400                        ctx,
401                        acme_proxy_jobs::auditor::admin::SessionScope::OneOf(
402                            target.username.clone(),
403                        ),
404                        1,
405                    )
406                })
407                .await;
408            tracing::info!(event = "admin_operator_session_revoked",
409                           outcome = "success",
410                           surface = surface,
411                           username = %caller.username,
412                           target_username = %target.username,
413                           session_fp = %fingerprint);
414        }
415        OperatorAction::SetContact { contact } => {
416            apply_contact_change(
417                state,
418                caller,
419                target,
420                contact,
421                client,
422                request_context,
423                surface,
424            )
425            .await?;
426        }
427        OperatorAction::SetRole { role } => {
428            let (updated, revoked) =
429                changes::change_role(&target.username, role, state.database.clone(), &trail)
430                    .await
431                    .map_err(user_error)?
432                    .ok_or_else(|| operator_not_found(&target.username))?;
433            *target = updated;
434            tracing::info!(event = "admin_operator_role_changed",
435                           outcome = "success",
436                           surface = surface,
437                           username = %caller.username,
438                           target_username = %target.username,
439                           role = role.as_str(),
440                           sessions_revoked = revoked);
441        }
442    }
443    Ok(())
444}
445
446/// Sets or clears `target`'s notification address, and owes everything a
447/// change of it does: the audit row, the log line, and the message to the
448/// address it replaced.
449///
450/// Shared by the operators surface (another operator's address) and
451/// `/account/contact` (one's own), which differ only in whether `caller` is
452/// `target`. Setting an address to what it already was writes no row and sends
453/// no message: telling somebody their alarms moved to the address they were
454/// already using is noise that teaches them to ignore the real one.
455pub(crate) async fn apply_contact_change(
456    state: &AdminState,
457    caller: &AdminUser,
458    target: &mut AdminUser,
459    contact: Option<&str>,
460    client: Option<std::net::IpAddr>,
461    request_context: &acme_proxy_core::audit::RequestContext,
462    surface: &'static str,
463) -> Result<(), AdminError> {
464    let trail = WebTrail {
465        state,
466        request_context,
467        actor: &caller.username,
468        client,
469        by_self: caller.id == target.id,
470    };
471    let (updated, changed) =
472        changes::change_contact(&target.username, contact, state.database.clone(), &trail)
473            .await
474            .map_err(user_error)?
475            .ok_or_else(|| operator_not_found(&target.username))?;
476    *target = updated;
477    if !changed {
478        return Ok(());
479    }
480    tracing::info!(event = "admin_operator_contact_updated",
481                   outcome = "success",
482                   surface = surface,
483                   username = %caller.username,
484                   target_username = %target.username,
485                   contact_set = target.contact_email.is_some());
486    Ok(())
487}
488
489/// A `users::` refusal in this surface's error shape.
490///
491/// No `From` impl on purpose: `UserError` is shared with the CLI, and which
492/// status a variant deserves depends on the operation. On the two operations
493/// this surface calls, `Policy` can only be `set_role`'s last-admin refusal —
494/// a statement about the operator set, not about the request, so a `409`.
495pub(crate) fn user_error(error: UserError) -> AdminError {
496    match error {
497        UserError::Policy(message) => AdminError::conflict("last_admin", message),
498        UserError::InvalidContact(message) => {
499            AdminError::with_code(StatusCode::BAD_REQUEST, "invalid_contact", message)
500        }
501        UserError::Database(error) => error.into(),
502        UserError::DuplicateUsername(_) => AdminError::internal(),
503    }
504}
505
506/// Refuses a route on this surface when its target is the caller.
507///
508/// Checked before [`verify_current_password`] runs, so a self-target is refused
509/// without making the caller type their password to be told no — every one of
510/// these actions already has a self-service home on `/api/account` or
511/// `/ui/account`.
512pub(crate) fn refuse_self_target(caller: &AdminUser, target: &AdminUser) -> Result<(), AdminError> {
513    if caller.id == target.id {
514        return Err(AdminError::bad_request(
515            "manage your own account from /ui/account, not the operators surface",
516        ));
517    }
518    Ok(())
519}
520
521pub(crate) async fn find(username: &str, state: &AdminState) -> Result<AdminUser, AdminError> {
522    AdminUser::find_by_username(username, &state.database)
523        .await?
524        .ok_or_else(|| operator_not_found(username))
525}
526
527fn operator_not_found(username: &str) -> AdminError {
528    AdminError::not_found(crate::admin::subject::Subject::Operator.missing(username))
529}
530
531fn session_not_found(id: &str) -> AdminError {
532    AdminError::not_found(crate::admin::subject::Subject::Session.missing(id))
533}