acme-proxy-admin 0.6.1

The operation layer and web admin panel of acme-proxy (internal crate, no semver promise)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
//! `/api/operators` — every operator this process has, and acting on one
//! *other* than the caller: disable, enable, reset their second factor, list
//! and revoke their sessions.
//!
//! Distinct from `/api/account` (`handlers::account`), which is the same
//! operator managing themselves. That split is the trust boundary this module
//! exists to enforce: every mutating route here runs
//! [`crate::webadmin::handlers::mfa::verify_current_password`], and every one
//! refuses a `username` that resolves to the caller — self-management stays on
//! `/api/account`, which already owns it, and never needs a password re-typed
//! to reach it.
//!
//! **`verify_current_password`, not `check_step_up`.** The latter passes
//! unconditionally for an operator with no second factor, which is right where
//! it was written — a first enrolment protects nothing, and a password there
//! would stand in front of the `require_mfa` bootstrap. It is wrong here: this
//! surface's blast radius is a *colleague's* account, which exists whether or
//! not the caller has enrolled, so a password-only admin holding a stolen
//! cookie could otherwise disable every other admin and wipe their factors
//! without typing anything. `handlers::account::change_password` already made
//! exactly this choice for its own ASVS V6.2.3 reason.
//!
//! The tail of every mutation is [`apply_operator_action`], shared with the
//! `/ui` twin (`crate::webadmin::pages::operators`): it calls
//! `crate::admin::changes`, which owns the write, the audit rows, the
//! revoked-sessions row and the notification for the CLI as well, and adds
//! the web surface's log line. Only the extractors, the `surface` field and
//! the response shape differ between `/api` and `/ui`.
//!
//! `create`/`passwd` are deliberately absent, on both this surface and the
//! page it backs: those mint a credential, which is where "no sign-up page"
//! already draws the line — see `acme-proxy admin user create`/`passwd` on the
//! host.

use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::Value;

use crate::admin;
use crate::admin::users::UserError;
use crate::admin::{changes, mfa, users};
use crate::webadmin::error::AdminError;
use crate::webadmin::handlers::mfa::{StepUpRequest, verify_current_password};
use crate::webadmin::handlers::paging::{PageParams, page_envelope};
use crate::webadmin::session::{AdminClientIp, AdminRead, AdminWrite};
use crate::webadmin::{AdminState, WebTrail};
use acme_proxy_store::admin_session::AdminSession;
use acme_proxy_store::admin_user::AdminRole;
use acme_proxy_store::admin_user::AdminStatus;
use acme_proxy_store::admin_user::AdminUser;

/// `GET /api/operators?limit=&offset=` — every operator, oldest first.
///
/// The same [`AdminUser::search`] `admin user list` reads, so the panel and the
/// terminal cannot come to describe the operator set differently.
pub async fn list_operators(
    State(state): State<AdminState>,
    Query(params): Query<PageParams>,
    _auth: AdminRead,
) -> Result<Json<Value>, AdminError> {
    let page = params.resolve(&state.config);
    let (operators, total) = users::list_users(page.limit, page.offset, state.database).await?;
    let items: Vec<Value> = operators
        .iter()
        .map(admin::render_admin_user_json)
        .collect();
    Ok(Json(page_envelope(items, total, page)))
}

/// `GET /api/operators/{username}` — one operator's detail, `admin user
/// show`'s shape.
pub async fn get_operator(
    State(state): State<AdminState>,
    Path(username): Path<String>,
    _auth: AdminRead,
) -> Result<Json<Value>, AdminError> {
    let user = find(&username, &state).await?;
    let remaining = mfa::recovery_codes_remaining(user.id, state.database.clone()).await?;
    Ok(Json(admin::render_admin_user_detail_json(&user, remaining)))
}

/// `GET /api/operators/{username}/sessions?limit=&offset=` — one operator's
/// live sessions, `admin session list --user`'s shape. No `current`
/// marker: the caller viewing another operator's sessions has none of their
/// own in this list, unlike `GET /api/account/sessions`.
pub async fn list_operator_sessions(
    State(state): State<AdminState>,
    Path(username): Path<String>,
    Query(params): Query<PageParams>,
    _auth: AdminRead,
) -> Result<Json<Value>, AdminError> {
    let user = find(&username, &state).await?;
    let page = params.resolve(&state.config);
    let (sessions, total) =
        AdminSession::search(Some(user.id), page.limit, page.offset, &state.database).await?;
    let items: Vec<Value> = sessions
        .iter()
        .map(admin::render_admin_session_json)
        .collect();
    Ok(Json(page_envelope(items, total, page)))
}
/// `POST /api/operators/{username}/disable`
pub async fn disable_operator(
    State(state): State<AdminState>,
    Path(username): Path<String>,
    AdminClientIp(client): AdminClientIp,
    AdminWrite(auth): AdminWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
    act(
        &state,
        &auth.user,
        &username,
        &body.unwrap_or_default().password,
        client,
        &request_context,
        OperatorAction::SetStatus { active: false },
    )
    .await?;
    Ok(StatusCode::NO_CONTENT.into_response())
}

/// `POST /api/operators/{username}/enable`
pub async fn enable_operator(
    State(state): State<AdminState>,
    Path(username): Path<String>,
    AdminClientIp(client): AdminClientIp,
    AdminWrite(auth): AdminWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
    act(
        &state,
        &auth.user,
        &username,
        &body.unwrap_or_default().password,
        client,
        &request_context,
        OperatorAction::SetStatus { active: true },
    )
    .await?;
    Ok(StatusCode::NO_CONTENT.into_response())
}

/// `POST /api/operators/{username}/totp/reset` — the web twin of
/// `acme-proxy admin user totp reset`: removes the factor, every recovery
/// code, and every session the operator holds.
pub async fn reset_operator_totp(
    State(state): State<AdminState>,
    Path(username): Path<String>,
    AdminClientIp(client): AdminClientIp,
    AdminWrite(auth): AdminWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
    act(
        &state,
        &auth.user,
        &username,
        &body.unwrap_or_default().password,
        client,
        &request_context,
        OperatorAction::ResetTotp,
    )
    .await?;
    Ok(StatusCode::NO_CONTENT.into_response())
}

/// `POST /api/operators/{username}/sessions/{id}/revoke`
pub async fn revoke_operator_session(
    State(state): State<AdminState>,
    Path((username, id)): Path<(String, String)>,
    AdminClientIp(client): AdminClientIp,
    AdminWrite(auth): AdminWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
    act(
        &state,
        &auth.user,
        &username,
        &body.unwrap_or_default().password,
        client,
        &request_context,
        OperatorAction::RevokeSession { fingerprint: &id },
    )
    .await?;
    Ok(StatusCode::NO_CONTENT.into_response())
}

/// The body of `POST /api/operators/{username}/contact`: the step-up password
/// and the address. An absent, `null` or blank `contact` clears it.
#[derive(Debug, Default, Deserialize)]
pub struct SetOperatorContactRequest {
    #[serde(default)]
    pub password: String,
    #[serde(default)]
    pub contact: Option<String>,
}

/// The body of `POST /api/operators/{username}/role`.
#[derive(Debug, Default, Deserialize)]
pub struct SetOperatorRoleRequest {
    #[serde(default)]
    pub password: String,
    #[serde(default)]
    pub role: String,
}

/// `POST /api/operators/{username}/contact` — the web twin of
/// `acme-proxy admin user contact`. Tells the address it replaced.
pub async fn set_operator_contact(
    State(state): State<AdminState>,
    Path(username): Path<String>,
    AdminClientIp(client): AdminClientIp,
    AdminWrite(auth): AdminWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    body: Option<Json<SetOperatorContactRequest>>,
) -> Result<Response, AdminError> {
    let body = body.unwrap_or_default();
    act(
        &state,
        &auth.user,
        &username,
        &body.password,
        client,
        &request_context,
        OperatorAction::SetContact {
            contact: body.contact.as_deref(),
        },
    )
    .await?;
    Ok(StatusCode::NO_CONTENT.into_response())
}

/// `POST /api/operators/{username}/role` — the web twin of
/// `acme-proxy admin user role`: moves the operator to another tier and revokes
/// every session they hold.
///
/// An unknown role is refused by name before anything else runs, `AdminRole`'s
/// own rule. Demoting the last `admin` is refused by `users::set_role`, but it
/// is not reachable from here: the caller is an `admin` and cannot target
/// themselves, so an `admin` target always leaves at least one.
pub async fn set_operator_role(
    State(state): State<AdminState>,
    Path(username): Path<String>,
    AdminClientIp(client): AdminClientIp,
    AdminWrite(auth): AdminWrite,
    request_context: acme_proxy_core::audit::RequestContext,
    body: Option<Json<SetOperatorRoleRequest>>,
) -> Result<Response, AdminError> {
    let body = body.unwrap_or_default();
    let role: AdminRole = body.role.parse().map_err(AdminError::bad_request)?;
    act(
        &state,
        &auth.user,
        &username,
        &body.password,
        client,
        &request_context,
        OperatorAction::SetRole { role },
    )
    .await?;
    Ok(StatusCode::NO_CONTENT.into_response())
}

/// The `/api` spelling of the shared sequence: resolve the target, refuse a
/// self-target, re-prove the caller's own password, then
/// [`apply_operator_action`].
///
/// The `/ui` twin runs the same four steps but renders the password refusal as
/// the operator card's own banner, so it calls the pieces itself rather than
/// this wrapper.
async fn act(
    state: &AdminState,
    caller: &AdminUser,
    username: &str,
    password: &str,
    client: Option<std::net::IpAddr>,
    request_context: &acme_proxy_core::audit::RequestContext,
    action: OperatorAction<'_>,
) -> Result<(), AdminError> {
    let mut target = find(username, state).await?;
    refuse_self_target(caller, &target)?;
    verify_current_password(caller, password, client, &state.logins).await?;
    apply_operator_action(
        state,
        caller,
        &mut target,
        action,
        client,
        request_context,
        "api",
    )
    .await
}

/// One colleague-management action, named once so the two front ends cannot
/// come to disagree about what each of them does.
#[derive(Debug)]
pub(crate) enum OperatorAction<'a> {
    /// Disable (`active: false`) or re-enable the operator. Disabling revokes
    /// every session they hold, inside `users::set_status`.
    SetStatus { active: bool },
    /// Remove their second factor, every recovery code, and every session.
    ResetTotp,
    /// End one of their sessions, named by the fingerprint the listing prints.
    RevokeSession { fingerprint: &'a str },
    /// Set (`Some`) or clear (`None` or blank) the address their security
    /// notifications go to.
    SetContact { contact: Option<&'a str> },
    /// Move them to another tier. Revokes every session they hold, inside
    /// `users::set_role`.
    SetRole { role: AdminRole },
}

/// Performs `action` through `crate::admin::changes`, which owns the write,
/// the audit row(s) and — where a credential of the operator's changed — the
/// notification to them, and adds the web surface's log line.
///
/// Shared by `/api` and `/ui`, as `changes` is shared with the CLI, so a row or
/// a notification cannot be written on one surface and forgotten on another,
/// which is what happened while each spelled this tail out for itself. The
/// caller has already resolved `target`, refused a self-target and re-proved
/// its own password; `surface` is the only thing it contributes here.
///
/// `target` is updated in place, so the page front end re-renders its card from
/// it rather than reading the row back.
pub(crate) async fn apply_operator_action(
    state: &AdminState,
    caller: &AdminUser,
    target: &mut AdminUser,
    action: OperatorAction<'_>,
    client: Option<std::net::IpAddr>,
    request_context: &acme_proxy_core::audit::RequestContext,
    surface: &'static str,
) -> Result<(), AdminError> {
    let trail = WebTrail {
        state,
        request_context,
        actor: &caller.username,
        client,
        by_self: caller.id == target.id,
    };
    match action {
        OperatorAction::SetStatus { active } => {
            let status = if active {
                AdminStatus::Active
            } else {
                AdminStatus::Disabled
            };
            // The `Option` is the raced-delete answer: the row was found a
            // moment ago and is gone now, so this reports "no such operator"
            // rather than a `204` with no write behind it.
            let (updated, revoked) =
                changes::change_status(&target.username, status, state.database.clone(), &trail)
                    .await?
                    .ok_or_else(|| operator_not_found(&target.username))?;
            *target = updated;
            if active {
                tracing::info!(event = "admin_operator_enabled",
                               outcome = "success",
                               surface = surface,
                               username = %caller.username,
                               target_username = %target.username);
            } else {
                tracing::info!(event = "admin_operator_disabled",
                               outcome = "success",
                               surface = surface,
                               username = %caller.username,
                               target_username = %target.username,
                               sessions_revoked = revoked);
            }
        }
        OperatorAction::ResetTotp => {
            // A *different* operator's factor (`refuse_self_target` ran), so
            // the trail's `by_self` is false — the same change `admin user
            // totp reset` makes.
            changes::reset_totp(target, state.database.clone(), &trail).await?;
            tracing::info!(event = "admin_operator_totp_reset",
                           outcome = "success",
                           surface = surface,
                           username = %caller.username,
                           target_username = %target.username);
        }
        OperatorAction::RevokeSession { fingerprint } => {
            let session =
                AdminSession::find_by_user_and_fingerprint(target.id, fingerprint, &state.database)
                    .await?
                    .ok_or_else(|| session_not_found(fingerprint))?;
            AdminSession::delete(&session.token_hash, &state.database).await?;
            state
                .record_admin_action(request_context, &caller.username, |actor, ctx| {
                    acme_proxy_jobs::auditor::admin::session_revoked(
                        actor,
                        ctx,
                        acme_proxy_jobs::auditor::admin::SessionScope::OneOf(
                            target.username.clone(),
                        ),
                        1,
                    )
                })
                .await;
            tracing::info!(event = "admin_operator_session_revoked",
                           outcome = "success",
                           surface = surface,
                           username = %caller.username,
                           target_username = %target.username,
                           session_fp = %fingerprint);
        }
        OperatorAction::SetContact { contact } => {
            apply_contact_change(
                state,
                caller,
                target,
                contact,
                client,
                request_context,
                surface,
            )
            .await?;
        }
        OperatorAction::SetRole { role } => {
            let (updated, revoked) =
                changes::change_role(&target.username, role, state.database.clone(), &trail)
                    .await
                    .map_err(user_error)?
                    .ok_or_else(|| operator_not_found(&target.username))?;
            *target = updated;
            tracing::info!(event = "admin_operator_role_changed",
                           outcome = "success",
                           surface = surface,
                           username = %caller.username,
                           target_username = %target.username,
                           role = role.as_str(),
                           sessions_revoked = revoked);
        }
    }
    Ok(())
}

/// Sets or clears `target`'s notification address, and owes everything a
/// change of it does: the audit row, the log line, and the message to the
/// address it replaced.
///
/// Shared by the operators surface (another operator's address) and
/// `/account/contact` (one's own), which differ only in whether `caller` is
/// `target`. Setting an address to what it already was writes no row and sends
/// no message: telling somebody their alarms moved to the address they were
/// already using is noise that teaches them to ignore the real one.
pub(crate) async fn apply_contact_change(
    state: &AdminState,
    caller: &AdminUser,
    target: &mut AdminUser,
    contact: Option<&str>,
    client: Option<std::net::IpAddr>,
    request_context: &acme_proxy_core::audit::RequestContext,
    surface: &'static str,
) -> Result<(), AdminError> {
    let trail = WebTrail {
        state,
        request_context,
        actor: &caller.username,
        client,
        by_self: caller.id == target.id,
    };
    let (updated, changed) =
        changes::change_contact(&target.username, contact, state.database.clone(), &trail)
            .await
            .map_err(user_error)?
            .ok_or_else(|| operator_not_found(&target.username))?;
    *target = updated;
    if !changed {
        return Ok(());
    }
    tracing::info!(event = "admin_operator_contact_updated",
                   outcome = "success",
                   surface = surface,
                   username = %caller.username,
                   target_username = %target.username,
                   contact_set = target.contact_email.is_some());
    Ok(())
}

/// A `users::` refusal in this surface's error shape.
///
/// No `From` impl on purpose: `UserError` is shared with the CLI, and which
/// status a variant deserves depends on the operation. On the two operations
/// this surface calls, `Policy` can only be `set_role`'s last-admin refusal —
/// a statement about the operator set, not about the request, so a `409`.
pub(crate) fn user_error(error: UserError) -> AdminError {
    match error {
        UserError::Policy(message) => AdminError::conflict("last_admin", message),
        UserError::InvalidContact(message) => {
            AdminError::with_code(StatusCode::BAD_REQUEST, "invalid_contact", message)
        }
        UserError::Database(error) => error.into(),
        UserError::DuplicateUsername(_) => AdminError::internal(),
    }
}

/// Refuses a route on this surface when its target is the caller.
///
/// Checked before [`verify_current_password`] runs, so a self-target is refused
/// without making the caller type their password to be told no — every one of
/// these actions already has a self-service home on `/api/account` or
/// `/ui/account`.
pub(crate) fn refuse_self_target(caller: &AdminUser, target: &AdminUser) -> Result<(), AdminError> {
    if caller.id == target.id {
        return Err(AdminError::bad_request(
            "manage your own account from /ui/account, not the operators surface",
        ));
    }
    Ok(())
}

pub(crate) async fn find(username: &str, state: &AdminState) -> Result<AdminUser, AdminError> {
    AdminUser::find_by_username(username, &state.database)
        .await?
        .ok_or_else(|| operator_not_found(username))
}

fn operator_not_found(username: &str) -> AdminError {
    AdminError::not_found(crate::admin::subject::Subject::Operator.missing(username))
}

fn session_not_found(id: &str) -> AdminError {
    AdminError::not_found(crate::admin::subject::Subject::Session.missing(id))
}