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;
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)))
}
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)))
}
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)))
}
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())
}
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())
}
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())
}
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())
}
#[derive(Debug, Default, Deserialize)]
pub struct SetOperatorContactRequest {
#[serde(default)]
pub password: String,
#[serde(default)]
pub contact: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct SetOperatorRoleRequest {
#[serde(default)]
pub password: String,
#[serde(default)]
pub role: String,
}
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())
}
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())
}
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
}
#[derive(Debug)]
pub(crate) enum OperatorAction<'a> {
SetStatus { active: bool },
ResetTotp,
RevokeSession { fingerprint: &'a str },
SetContact { contact: Option<&'a str> },
SetRole { role: AdminRole },
}
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
};
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 => {
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(())
}
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(())
}
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(),
}
}
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))
}