1use 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
55pub 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
73pub 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
85pub 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}
105pub 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
127pub 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
149pub 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
173pub 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#[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#[derive(Debug, Default, Deserialize)]
207pub struct SetOperatorRoleRequest {
208 #[serde(default)]
209 pub password: String,
210 #[serde(default)]
211 pub role: String,
212}
213
214pub 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
240pub 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
271async 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#[derive(Debug)]
305pub(crate) enum OperatorAction<'a> {
306 SetStatus { active: bool },
309 ResetTotp,
311 RevokeSession { fingerprint: &'a str },
313 SetContact { contact: Option<&'a str> },
316 SetRole { role: AdminRole },
319}
320
321pub(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 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 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
446pub(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
489pub(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
506pub(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}