use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{app::AppState, audit, auth::hash_token, error::ApiError, login::CurrentUser, model::UserRole, session};
const MIN_PASSWORD_LENGTH: usize = 8;
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct UserRow {
pub id: Uuid,
pub email: String,
pub display_name: String,
pub role: UserRole,
pub active: bool,
pub has_password: bool,
pub agents: i64,
pub last_seen_at: Option<DateTime<Utc>>,
pub department_id: Option<Uuid>,
pub department: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct NewUser {
pub email: String,
pub display_name: Option<String>,
#[serde(default = "default_role")]
pub role: UserRole,
pub password: Option<String>,
}
fn default_role() -> UserRole {
UserRole::Employee
}
#[derive(Debug, Deserialize)]
pub struct UserPatch {
pub display_name: Option<String>,
pub role: Option<UserRole>,
pub active: Option<bool>,
pub password: Option<String>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct AgentRow {
pub id: Uuid,
pub name: String,
pub revoked_at: Option<DateTime<Utc>>,
pub last_seen_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct NewAgent {
pub name: String,
}
#[derive(Debug, Serialize)]
pub struct IssuedAgent {
pub id: Uuid,
pub name: String,
pub token: String,
pub notice: &'static str,
}
pub async fn list_users(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
require_manager_or_admin(&user)?;
let users: Vec<UserRow> = sqlx::query_as(
"SELECT u.id, u.email, u.display_name, u.role, u.active,
(u.password_hash IS NOT NULL) AS has_password,
(SELECT count(*) FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS agents,
(SELECT max(a.last_seen_at) FROM agents a WHERE a.user_id = u.id) AS last_seen_at,
u.department_id,
d.name AS department,
u.created_at
FROM users u
LEFT JOIN departments d ON d.id = u.department_id
WHERE $1
OR u.id = $2
OR u.department_id IN (SELECT id FROM departments WHERE manager_id = $2)
ORDER BY u.display_name, u.email",
)
.bind(user.role == UserRole::Admin)
.bind(user.user_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(users))
}
pub async fn create_user(State(state): State<AppState>, user: CurrentUser, Json(new): Json<NewUser>) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
let email = new.email.trim();
if !looks_like_an_email(email) {
return Err(ApiError::bad_request("that does not look like an email address"));
}
let password_hash = match new.password.as_deref() {
Some(password) => Some(hash_new_password(password)?),
None => None,
};
let display_name = new
.display_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or_else(|| email.split('@').next().unwrap_or(email));
let created: Result<Uuid, sqlx::Error> =
sqlx::query_scalar("INSERT INTO users (email, display_name, role, password_hash) VALUES ($1, $2, $3, $4) RETURNING id")
.bind(email)
.bind(display_name)
.bind(new.role)
.bind(password_hash.as_deref())
.fetch_one(&state.pool)
.await;
let id = match created {
Ok(id) => id,
Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
return Err(ApiError::new(StatusCode::CONFLICT, "someone with that email address already exists"));
}
Err(error) => return Err(error.into()),
};
tracing::info!(%id, by = %user.user_id, "created a user");
audit::Entry::new(audit::action::USER_CREATED)
.by(user.user_id)
.by_email(&user.email)
.on(id)
.labelled(email)
.with(serde_json::json!({"role": new.role, "with_password": password_hash.is_some()}))
.record(&state.pool)
.await;
Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))))
}
pub async fn update_user(
State(state): State<AppState>,
user: CurrentUser,
Path(target): Path<Uuid>,
Json(patch): Json<UserPatch>,
) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
let losing_admin = patch.role.is_some_and(|role| role != UserRole::Admin) || patch.active == Some(false);
if losing_admin && is_last_admin(&state.pool, target).await? {
return Err(ApiError::new(
StatusCode::CONFLICT,
"this is the only administrator; promote someone else first",
));
}
let password_hash = match patch.password.as_deref() {
Some(password) => Some(hash_new_password(password)?),
None => None,
};
let updated = sqlx::query(
"UPDATE users SET
display_name = coalesce($2, display_name),
role = coalesce($3, role),
active = coalesce($4, active),
password_hash = coalesce($5, password_hash)
WHERE id = $1",
)
.bind(target)
.bind(patch.display_name.as_deref().map(str::trim).filter(|name| !name.is_empty()))
.bind(patch.role)
.bind(patch.active)
.bind(password_hash.as_deref())
.execute(&state.pool)
.await?
.rows_affected();
if updated == 0 {
return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
}
if patch.active == Some(false) || patch.password.is_some() {
let ended = session::revoke_all(&state.pool, target).await?;
tracing::info!(%target, ended, "ended sessions after a change to the account");
}
tracing::info!(%target, by = %user.user_id, "updated a user");
audit::Entry::new(audit::action::USER_UPDATED)
.by(user.user_id)
.by_email(&user.email)
.on(target)
.with(serde_json::json!({
"display_name": patch.display_name.is_some(),
"role": patch.role,
"active": patch.active,
"password_reset": patch.password.is_some(),
}))
.record(&state.pool)
.await;
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_agents(State(state): State<AppState>, user: CurrentUser, Path(target): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
require_manager_or_admin(&user)?;
let agents: Vec<AgentRow> = sqlx::query_as("SELECT id, name, revoked_at, last_seen_at, created_at FROM agents WHERE user_id = $1 ORDER BY created_at")
.bind(target)
.fetch_all(&state.pool)
.await?;
Ok(Json(agents))
}
pub async fn create_agent(
State(state): State<AppState>,
user: CurrentUser,
Path(target): Path<Uuid>,
Json(new): Json<NewAgent>,
) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
let name = new.name.trim();
if name.is_empty() {
return Err(ApiError::bad_request("an agent needs a name; the machine it runs on is the usual one"));
}
let active: Option<bool> = sqlx::query_scalar("SELECT active FROM users WHERE id = $1")
.bind(target)
.fetch_optional(&state.pool)
.await?;
match active {
None => return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user")),
Some(false) => return Err(ApiError::new(StatusCode::CONFLICT, "that account is deactivated")),
Some(true) => {}
}
let token = generate_token();
let id: Uuid = sqlx::query_scalar("INSERT INTO agents (user_id, name, token_hash) VALUES ($1, $2, $3) RETURNING id")
.bind(target)
.bind(name)
.bind(hash_token(&token))
.fetch_one(&state.pool)
.await?;
tracing::info!(%id, %target, by = %user.user_id, "issued an agent token");
audit::Entry::new(audit::action::AGENT_ISSUED)
.by(user.user_id)
.by_email(&user.email)
.on(id)
.labelled(name)
.with(serde_json::json!({"user_id": target}))
.record(&state.pool)
.await;
Ok((
StatusCode::CREATED,
Json(IssuedAgent {
id,
name: name.to_string(),
token,
notice: "this token is shown once; the server keeps only its hash",
}),
))
}
pub async fn revoke_agent(State(state): State<AppState>, user: CurrentUser, Path(agent): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
user.require_admin()?;
let revoked = sqlx::query("UPDATE agents SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL")
.bind(agent)
.execute(&state.pool)
.await?
.rows_affected();
if revoked == 0 {
let exists: Option<Uuid> = sqlx::query_scalar("SELECT id FROM agents WHERE id = $1")
.bind(agent)
.fetch_optional(&state.pool)
.await?;
if exists.is_none() {
return Err(ApiError::new(StatusCode::NOT_FOUND, "no such agent"));
}
}
tracing::info!(%agent, by = %user.user_id, "revoked an agent token");
audit::Entry::new(audit::action::AGENT_REVOKED)
.by(user.user_id)
.by_email(&user.email)
.on(agent)
.with(serde_json::json!({"already_revoked": revoked == 0}))
.record(&state.pool)
.await;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
pub struct PasswordChange {
pub current: String,
pub new: String,
}
pub async fn change_own_password(State(state): State<AppState>, user: CurrentUser, Json(change): Json<PasswordChange>) -> Result<impl IntoResponse, ApiError> {
let stored: Option<String> = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
.bind(user.user_id)
.fetch_one(&state.pool)
.await?;
let Some(stored) = stored else {
return Err(ApiError::new(StatusCode::CONFLICT, "this account has no password to change"));
};
if !session::verify_password(&change.current, &stored) {
return Err(ApiError::new(StatusCode::UNAUTHORIZED, "the current password is wrong"));
}
let hash = hash_new_password(&change.new)?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(&hash)
.bind(user.user_id)
.execute(&state.pool)
.await?;
sqlx::query("DELETE FROM sessions WHERE user_id = $1 AND id <> $2")
.bind(user.user_id)
.bind(user.session_id)
.execute(&state.pool)
.await?;
tracing::info!(user_id = %user.user_id, "changed their password");
audit::Entry::new(audit::action::PASSWORD_CHANGED)
.by(user.user_id)
.by_email(&user.email)
.on(user.user_id)
.record(&state.pool)
.await;
Ok(StatusCode::NO_CONTENT)
}
fn require_manager_or_admin(user: &CurrentUser) -> Result<(), ApiError> {
match user.role {
UserRole::Admin | UserRole::Manager => Ok(()),
UserRole::Employee => Err(ApiError::new(StatusCode::FORBIDDEN, "not allowed")),
}
}
fn hash_new_password(password: &str) -> Result<String, ApiError> {
if password.chars().count() < MIN_PASSWORD_LENGTH {
return Err(ApiError::bad_request(format!("the password must be at least {MIN_PASSWORD_LENGTH} characters")));
}
session::hash_password(password).map_err(Into::into)
}
fn generate_token() -> String {
use rand::RngExt;
let bytes: [u8; 32] = rand::rng().random();
bytes.iter().fold(String::from("kasl_"), |mut acc, byte| {
use std::fmt::Write;
let _ = write!(acc, "{byte:02x}");
acc
})
}
fn looks_like_an_email(candidate: &str) -> bool {
match candidate.split_once('@') {
Some((local, domain)) => !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.'),
None => false,
}
}
async fn is_last_admin(pool: &sqlx::PgPool, target: Uuid) -> Result<bool, ApiError> {
let others: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE role = 'admin' AND active AND id <> $1")
.bind(target)
.fetch_one(pool)
.await?;
let is_admin: Option<bool> = sqlx::query_scalar("SELECT (role = 'admin' AND active) FROM users WHERE id = $1")
.bind(target)
.fetch_optional(pool)
.await?;
Ok(is_admin.unwrap_or(false) && others == 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_token_is_long_random_and_recognisable() {
let token = generate_token();
assert!(token.starts_with("kasl_"), "a token in a log should be identifiable: {token}");
assert_eq!(token.len(), 5 + 64, "32 bytes as hex");
assert_ne!(token, generate_token(), "two tokens must never be the same");
}
#[test]
fn a_short_password_is_refused_before_it_is_hashed() {
let error = hash_new_password("short").expect_err("seven characters is not a password");
assert!(error.to_string().contains("at least 8"), "{error}");
assert!(hash_new_password("just long enough").is_ok());
}
#[test]
fn the_email_check_admits_addresses_and_refuses_obvious_mistakes() {
for good in ["a@b.co", "first.last@example.com", "kirill+kasl@example.co.uk"] {
assert!(looks_like_an_email(good), "{good} should be accepted");
}
for bad in ["kirill", "kirill@", "@example.com", "kirill@example", "kirill@example.com."] {
assert!(!looks_like_an_email(bad), "{bad} should be refused");
}
}
#[test]
fn a_manager_reads_and_an_employee_does_not() {
let user = |role| CurrentUser {
session_id: Uuid::nil(),
user_id: Uuid::nil(),
role,
email: "someone@example.test".to_string(),
};
assert!(require_manager_or_admin(&user(UserRole::Admin)).is_ok());
assert!(require_manager_or_admin(&user(UserRole::Manager)).is_ok());
assert!(require_manager_or_admin(&user(UserRole::Employee)).is_err());
assert!(user(UserRole::Manager).require_admin().is_err());
}
#[test]
fn an_absent_patch_field_means_leave_it_alone() {
let patch: UserPatch = serde_json::from_value(serde_json::json!({"display_name": "Kirill"})).unwrap();
assert_eq!(patch.display_name.as_deref(), Some("Kirill"));
assert!(patch.role.is_none() && patch.active.is_none() && patch.password.is_none());
}
#[test]
fn a_new_user_defaults_to_the_least_authority() {
let new: NewUser = serde_json::from_value(serde_json::json!({"email": "a@b.co"})).unwrap();
assert_eq!(new.role, UserRole::Employee, "a role must be asked for, never assumed");
assert!(new.password.is_none(), "an account for an agent needs no password");
}
}