use crate::auth::{passwords, Role};
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, patch},
Json, Router,
};
use kyma_core::catalog::Catalog;
use serde::Deserialize;
use serde_json::json;
use std::sync::Arc;
const MIN_PASSWORD_LEN: usize = 8;
#[derive(Clone)]
pub struct AdminState {
pub catalog: Arc<dyn Catalog>,
}
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
pub role: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateUserRequest {
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub password: Option<String>,
}
fn err(status: StatusCode, code: &str, message: &str) -> Response {
(
status,
Json(json!({ "error": { "code": code, "message": message } })),
)
.into_response()
}
fn would_remove_last_admin(current_role: &str, new_role: Option<&str>, admin_count: usize) -> bool {
let losing_an_admin = current_role == "admin" && new_role.map_or(true, |r| r != "admin");
losing_an_admin && admin_count <= 1
}
async fn admin_count(catalog: &dyn Catalog) -> Result<usize, Response> {
let users = catalog
.list_users()
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, "catalog", &e.to_string()))?;
Ok(users.iter().filter(|u| u.role == "admin").count())
}
async fn list_users_handler(State(state): State<AdminState>) -> Response {
match state.catalog.list_users().await {
Ok(users) => (StatusCode::OK, Json(users)).into_response(),
Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, "catalog", &e.to_string()),
}
}
async fn create_user_handler(
State(state): State<AdminState>,
Json(body): Json<CreateUserRequest>,
) -> Response {
let username = body.username.trim();
if username.is_empty() {
return err(StatusCode::BAD_REQUEST, "invalid", "username is required");
}
if Role::parse(&body.role).is_none() {
return err(
StatusCode::BAD_REQUEST,
"invalid_role",
"role must be one of: read, write, admin",
);
}
if body.password.len() < MIN_PASSWORD_LEN {
return err(
StatusCode::BAD_REQUEST,
"invalid",
"password must be at least 8 characters",
);
}
match state.catalog.get_user_with_hash(username).await {
Ok(Some(_)) => return err(StatusCode::CONFLICT, "exists", "user already exists"),
Ok(None) => {}
Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, "catalog", &e.to_string()),
}
let phc = match passwords::hash_password(&body.password) {
Ok(h) => h,
Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, "hash", &e),
};
match state.catalog.create_user(username, &phc, &body.role).await {
Ok(user) => (StatusCode::CREATED, Json(user)).into_response(),
Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, "catalog", &e.to_string()),
}
}
async fn update_user_handler(
State(state): State<AdminState>,
Path(username): Path<String>,
Json(body): Json<UpdateUserRequest>,
) -> Response {
let (user, _hash) = match state.catalog.get_user_with_hash(&username).await {
Ok(Some(pair)) => pair,
Ok(None) => return err(StatusCode::NOT_FOUND, "not_found", "user not found"),
Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, "catalog", &e.to_string()),
};
if body.role.is_none() && body.password.is_none() {
return err(
StatusCode::BAD_REQUEST,
"invalid",
"nothing to update (provide role and/or password)",
);
}
if let Some(role) = &body.role {
if Role::parse(role).is_none() {
return err(
StatusCode::BAD_REQUEST,
"invalid_role",
"role must be one of: read, write, admin",
);
}
if user.role == "admin" && role != "admin" {
let count = match admin_count(state.catalog.as_ref()).await {
Ok(c) => c,
Err(resp) => return resp,
};
if would_remove_last_admin(&user.role, Some(role), count) {
return err(
StatusCode::CONFLICT,
"last_admin",
"cannot demote the only admin",
);
}
}
if let Err(e) = state.catalog.set_user_role(&username, role).await {
return err(StatusCode::INTERNAL_SERVER_ERROR, "catalog", &e.to_string());
}
}
if let Some(password) = &body.password {
if password.len() < MIN_PASSWORD_LEN {
return err(
StatusCode::BAD_REQUEST,
"invalid",
"password must be at least 8 characters",
);
}
let phc = match passwords::hash_password(password) {
Ok(h) => h,
Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, "hash", &e),
};
if let Err(e) = state.catalog.set_user_password(&username, &phc).await {
return err(StatusCode::INTERNAL_SERVER_ERROR, "catalog", &e.to_string());
}
}
match state.catalog.get_user_with_hash(&username).await {
Ok(Some((user, _))) => (StatusCode::OK, Json(user)).into_response(),
_ => StatusCode::NO_CONTENT.into_response(),
}
}
async fn delete_user_handler(
State(state): State<AdminState>,
Path(username): Path<String>,
) -> Response {
let (user, _) = match state.catalog.get_user_with_hash(&username).await {
Ok(Some(pair)) => pair,
Ok(None) => return err(StatusCode::NOT_FOUND, "not_found", "user not found"),
Err(e) => return err(StatusCode::INTERNAL_SERVER_ERROR, "catalog", &e.to_string()),
};
if user.role == "admin" {
let count = match admin_count(state.catalog.as_ref()).await {
Ok(c) => c,
Err(resp) => return resp,
};
if would_remove_last_admin(&user.role, None, count) {
return err(
StatusCode::CONFLICT,
"last_admin",
"cannot delete the only admin",
);
}
}
match state.catalog.delete_user(&username).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => err(StatusCode::NOT_FOUND, "not_found", "user not found"),
Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, "catalog", &e.to_string()),
}
}
pub fn admin_users_router(catalog: Arc<dyn Catalog>) -> Router {
Router::new()
.route(
"/v1/admin/users",
get(list_users_handler).post(create_user_handler),
)
.route(
"/v1/admin/users/:username",
patch(update_user_handler).delete(delete_user_handler),
)
.with_state(AdminState { catalog })
}
#[cfg(test)]
mod tests {
use super::would_remove_last_admin;
#[test]
fn delete_sole_admin_is_blocked() {
assert!(would_remove_last_admin("admin", None, 1));
}
#[test]
fn delete_admin_when_others_exist_is_allowed() {
assert!(!would_remove_last_admin("admin", None, 2));
}
#[test]
fn deleting_non_admin_is_always_allowed() {
assert!(!would_remove_last_admin("write", None, 1));
assert!(!would_remove_last_admin("read", None, 0));
}
#[test]
fn demoting_sole_admin_is_blocked() {
assert!(would_remove_last_admin("admin", Some("write"), 1));
assert!(would_remove_last_admin("admin", Some("read"), 1));
}
#[test]
fn demoting_admin_when_others_exist_is_allowed() {
assert!(!would_remove_last_admin("admin", Some("write"), 3));
}
#[test]
fn keeping_admin_role_is_never_a_demotion() {
assert!(!would_remove_last_admin("admin", Some("admin"), 1));
}
}