use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use serde_json::Value;
use crate::admin;
use crate::admin::{mfa, users};
use crate::sqlite::admin_session::AdminSession;
use crate::sqlite::admin_user::{AdminStatus, AdminUser};
use crate::webadmin::AdminState;
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};
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,
headers: HeaderMap,
AdminWrite(auth): AdminWrite,
request_context: crate::audit::RequestContext,
body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
act(
&state,
&auth.user,
&username,
&body.unwrap_or_default().password,
client,
&headers,
&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,
headers: HeaderMap,
AdminWrite(auth): AdminWrite,
request_context: crate::audit::RequestContext,
body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
act(
&state,
&auth.user,
&username,
&body.unwrap_or_default().password,
client,
&headers,
&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,
headers: HeaderMap,
AdminWrite(auth): AdminWrite,
request_context: crate::audit::RequestContext,
body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
act(
&state,
&auth.user,
&username,
&body.unwrap_or_default().password,
client,
&headers,
&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,
headers: HeaderMap,
AdminWrite(auth): AdminWrite,
request_context: crate::audit::RequestContext,
body: Option<Json<StepUpRequest>>,
) -> Result<Response, AdminError> {
act(
&state,
&auth.user,
&username,
&body.unwrap_or_default().password,
client,
&headers,
&request_context,
OperatorAction::RevokeSession { fingerprint: &id },
)
.await?;
Ok(StatusCode::NO_CONTENT.into_response())
}
#[allow(clippy::too_many_arguments)]
async fn act(
state: &AdminState,
caller: &AdminUser,
username: &str,
password: &str,
client: Option<std::net::IpAddr>,
headers: &HeaderMap,
request_context: &crate::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)?;
apply_operator_action(
state,
caller,
&mut target,
action,
client,
headers,
request_context,
"api",
)
.await
}
#[derive(Debug)]
pub(crate) enum OperatorAction<'a> {
SetStatus { active: bool },
ResetTotp,
RevokeSession { fingerprint: &'a str },
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn apply_operator_action(
state: &AdminState,
caller: &AdminUser,
target: &mut AdminUser,
action: OperatorAction<'_>,
client: Option<std::net::IpAddr>,
headers: &HeaderMap,
request_context: &crate::audit::RequestContext,
surface: &'static str,
) -> Result<(), AdminError> {
match action {
OperatorAction::SetStatus { active } => {
let status = if active {
AdminStatus::Active
} else {
AdminStatus::Disabled
};
let (updated, revoked) =
users::set_status(&target.username, status, state.database.clone())
.await?
.ok_or_else(|| operator_not_found(&target.username))?;
*target = updated;
state
.record_admin_action(request_context, &caller.username, |actor, ctx| {
crate::audit::admin::operator_status_changed(
actor,
ctx,
&target.username,
active,
)
})
.await;
if revoked > 0 {
state
.record_admin_action(request_context, &caller.username, |actor, ctx| {
crate::audit::admin::session_revoked(
actor,
ctx,
crate::audit::admin::SessionScope::AllOf(target.username.clone()),
revoked,
)
})
.await;
}
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 => {
mfa::disable_totp(target, None, state.database.clone()).await?;
state
.record_credential_change(
request_context,
&caller.username,
target,
crate::notify::AdminCredentialChange::SecondFactorDisabled,
false,
client,
crate::webadmin::user_agent_of(headers),
)
.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| {
crate::audit::admin::session_revoked(
actor,
ctx,
crate::audit::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);
}
}
Ok(())
}
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(format!("no such operator: {username}"))
}
fn session_not_found(id: &str) -> AdminError {
AdminError::not_found(format!("no such session: {id}"))
}