1use axum::{
14 Json,
15 extract::{Path, State},
16 http::StatusCode,
17 response::IntoResponse,
18};
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use crate::{app::AppState, audit, auth::hash_token, error::ApiError, login::CurrentUser, model::UserRole, session};
24
25const MIN_PASSWORD_LENGTH: usize = 8;
31
32#[derive(Debug, Serialize, sqlx::FromRow)]
34pub struct UserRow {
35 pub id: Uuid,
36 pub email: String,
37 pub display_name: String,
38 pub role: UserRole,
39 pub active: bool,
40 pub has_password: bool,
44 pub agents: i64,
46 pub last_seen_at: Option<DateTime<Utc>>,
48 pub department_id: Option<Uuid>,
49 pub department: Option<String>,
52 pub created_at: DateTime<Utc>,
53}
54
55#[derive(Debug, Deserialize)]
56pub struct NewUser {
57 pub email: String,
58 pub display_name: Option<String>,
59 #[serde(default = "default_role")]
60 pub role: UserRole,
61 pub password: Option<String>,
64}
65
66fn default_role() -> UserRole {
67 UserRole::Employee
68}
69
70#[derive(Debug, Deserialize)]
73pub struct UserPatch {
74 pub display_name: Option<String>,
75 pub role: Option<UserRole>,
76 pub active: Option<bool>,
77 pub password: Option<String>,
81}
82
83#[derive(Debug, Serialize, sqlx::FromRow)]
85pub struct AgentRow {
86 pub id: Uuid,
87 pub name: String,
88 pub revoked_at: Option<DateTime<Utc>>,
89 pub last_seen_at: Option<DateTime<Utc>>,
90 pub created_at: DateTime<Utc>,
91}
92
93#[derive(Debug, Deserialize)]
94pub struct NewAgent {
95 pub name: String,
97}
98
99#[derive(Debug, Serialize)]
101pub struct IssuedAgent {
102 pub id: Uuid,
103 pub name: String,
104 pub token: String,
105 pub notice: &'static str,
108}
109
110pub async fn list_users(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
120 require_manager_or_admin(&user)?;
121
122 let users: Vec<UserRow> = sqlx::query_as(
123 "SELECT u.id, u.email, u.display_name, u.role, u.active,
124 (u.password_hash IS NOT NULL) AS has_password,
125 (SELECT count(*) FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS agents,
126 (SELECT max(a.last_seen_at) FROM agents a WHERE a.user_id = u.id) AS last_seen_at,
127 u.department_id,
128 d.name AS department,
129 u.created_at
130 FROM users u
131 LEFT JOIN departments d ON d.id = u.department_id
132 WHERE $1
133 OR u.id = $2
134 OR u.department_id IN (SELECT id FROM departments WHERE manager_id = $2)
135 ORDER BY u.display_name, u.email",
136 )
137 .bind(user.role == UserRole::Admin)
138 .bind(user.user_id)
139 .fetch_all(&state.pool)
140 .await?;
141
142 Ok(Json(users))
143}
144
145pub async fn create_user(State(state): State<AppState>, user: CurrentUser, Json(new): Json<NewUser>) -> Result<impl IntoResponse, ApiError> {
147 user.require_admin()?;
148
149 let email = new.email.trim();
150 if !looks_like_an_email(email) {
151 return Err(ApiError::bad_request("that does not look like an email address"));
152 }
153 let password_hash = match new.password.as_deref() {
154 Some(password) => Some(hash_new_password(password)?),
155 None => None,
156 };
157 let display_name = new
160 .display_name
161 .as_deref()
162 .map(str::trim)
163 .filter(|name| !name.is_empty())
164 .unwrap_or_else(|| email.split('@').next().unwrap_or(email));
165
166 let created: Result<Uuid, sqlx::Error> =
167 sqlx::query_scalar("INSERT INTO users (email, display_name, role, password_hash) VALUES ($1, $2, $3, $4) RETURNING id")
168 .bind(email)
169 .bind(display_name)
170 .bind(new.role)
171 .bind(password_hash.as_deref())
172 .fetch_one(&state.pool)
173 .await;
174
175 let id = match created {
176 Ok(id) => id,
177 Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
181 return Err(ApiError::new(StatusCode::CONFLICT, "someone with that email address already exists"));
182 }
183 Err(error) => return Err(error.into()),
184 };
185
186 tracing::info!(%id, by = %user.user_id, "created a user");
187 audit::Entry::new(audit::action::USER_CREATED)
188 .by(user.user_id)
189 .by_email(&user.email)
190 .on(id)
191 .labelled(email)
192 .with(serde_json::json!({"role": new.role, "with_password": password_hash.is_some()}))
193 .record(&state.pool)
194 .await;
195
196 Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))))
197}
198
199pub async fn update_user(
201 State(state): State<AppState>,
202 user: CurrentUser,
203 Path(target): Path<Uuid>,
204 Json(patch): Json<UserPatch>,
205) -> Result<impl IntoResponse, ApiError> {
206 user.require_admin()?;
207
208 let losing_admin = patch.role.is_some_and(|role| role != UserRole::Admin) || patch.active == Some(false);
213 if losing_admin && is_last_admin(&state.pool, target).await? {
214 return Err(ApiError::new(
215 StatusCode::CONFLICT,
216 "this is the only administrator; promote someone else first",
217 ));
218 }
219
220 let password_hash = match patch.password.as_deref() {
221 Some(password) => Some(hash_new_password(password)?),
222 None => None,
223 };
224
225 let updated = sqlx::query(
226 "UPDATE users SET
227 display_name = coalesce($2, display_name),
228 role = coalesce($3, role),
229 active = coalesce($4, active),
230 password_hash = coalesce($5, password_hash)
231 WHERE id = $1",
232 )
233 .bind(target)
234 .bind(patch.display_name.as_deref().map(str::trim).filter(|name| !name.is_empty()))
235 .bind(patch.role)
236 .bind(patch.active)
237 .bind(password_hash.as_deref())
238 .execute(&state.pool)
239 .await?
240 .rows_affected();
241
242 if updated == 0 {
243 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
244 }
245
246 if patch.active == Some(false) || patch.password.is_some() {
250 let ended = session::revoke_all(&state.pool, target).await?;
251 tracing::info!(%target, ended, "ended sessions after a change to the account");
252 }
253
254 tracing::info!(%target, by = %user.user_id, "updated a user");
255 audit::Entry::new(audit::action::USER_UPDATED)
258 .by(user.user_id)
259 .by_email(&user.email)
260 .on(target)
261 .with(serde_json::json!({
262 "display_name": patch.display_name.is_some(),
263 "role": patch.role,
264 "active": patch.active,
265 "password_reset": patch.password.is_some(),
266 }))
267 .record(&state.pool)
268 .await;
269
270 Ok(StatusCode::NO_CONTENT)
271}
272
273pub async fn list_agents(State(state): State<AppState>, user: CurrentUser, Path(target): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
276 require_manager_or_admin(&user)?;
277
278 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")
279 .bind(target)
280 .fetch_all(&state.pool)
281 .await?;
282
283 Ok(Json(agents))
284}
285
286pub async fn create_agent(
288 State(state): State<AppState>,
289 user: CurrentUser,
290 Path(target): Path<Uuid>,
291 Json(new): Json<NewAgent>,
292) -> Result<impl IntoResponse, ApiError> {
293 user.require_admin()?;
294
295 let name = new.name.trim();
296 if name.is_empty() {
297 return Err(ApiError::bad_request("an agent needs a name; the machine it runs on is the usual one"));
298 }
299
300 let active: Option<bool> = sqlx::query_scalar("SELECT active FROM users WHERE id = $1")
304 .bind(target)
305 .fetch_optional(&state.pool)
306 .await?;
307 match active {
308 None => return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user")),
309 Some(false) => return Err(ApiError::new(StatusCode::CONFLICT, "that account is deactivated")),
310 Some(true) => {}
311 }
312
313 let token = generate_token();
314 let id: Uuid = sqlx::query_scalar("INSERT INTO agents (user_id, name, token_hash) VALUES ($1, $2, $3) RETURNING id")
315 .bind(target)
316 .bind(name)
317 .bind(hash_token(&token))
318 .fetch_one(&state.pool)
319 .await?;
320
321 tracing::info!(%id, %target, by = %user.user_id, "issued an agent token");
322 audit::Entry::new(audit::action::AGENT_ISSUED)
325 .by(user.user_id)
326 .by_email(&user.email)
327 .on(id)
328 .labelled(name)
329 .with(serde_json::json!({"user_id": target}))
330 .record(&state.pool)
331 .await;
332
333 Ok((
334 StatusCode::CREATED,
335 Json(IssuedAgent {
336 id,
337 name: name.to_string(),
338 token,
339 notice: "this token is shown once; the server keeps only its hash",
340 }),
341 ))
342}
343
344pub async fn revoke_agent(State(state): State<AppState>, user: CurrentUser, Path(agent): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
346 user.require_admin()?;
347
348 let revoked = sqlx::query("UPDATE agents SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL")
352 .bind(agent)
353 .execute(&state.pool)
354 .await?
355 .rows_affected();
356
357 if revoked == 0 {
358 let exists: Option<Uuid> = sqlx::query_scalar("SELECT id FROM agents WHERE id = $1")
361 .bind(agent)
362 .fetch_optional(&state.pool)
363 .await?;
364 if exists.is_none() {
365 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such agent"));
366 }
367 }
368
369 tracing::info!(%agent, by = %user.user_id, "revoked an agent token");
370 audit::Entry::new(audit::action::AGENT_REVOKED)
371 .by(user.user_id)
372 .by_email(&user.email)
373 .on(agent)
374 .with(serde_json::json!({"already_revoked": revoked == 0}))
375 .record(&state.pool)
376 .await;
377
378 Ok(StatusCode::NO_CONTENT)
379}
380
381#[derive(Debug, Deserialize)]
386pub struct PasswordChange {
387 pub current: String,
388 pub new: String,
389}
390
391pub async fn change_own_password(State(state): State<AppState>, user: CurrentUser, Json(change): Json<PasswordChange>) -> Result<impl IntoResponse, ApiError> {
392 let stored: Option<String> = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
393 .bind(user.user_id)
394 .fetch_one(&state.pool)
395 .await?;
396
397 let Some(stored) = stored else {
400 return Err(ApiError::new(StatusCode::CONFLICT, "this account has no password to change"));
401 };
402 if !session::verify_password(&change.current, &stored) {
403 return Err(ApiError::new(StatusCode::UNAUTHORIZED, "the current password is wrong"));
404 }
405
406 let hash = hash_new_password(&change.new)?;
407 sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
408 .bind(&hash)
409 .bind(user.user_id)
410 .execute(&state.pool)
411 .await?;
412
413 sqlx::query("DELETE FROM sessions WHERE user_id = $1 AND id <> $2")
417 .bind(user.user_id)
418 .bind(user.session_id)
419 .execute(&state.pool)
420 .await?;
421
422 tracing::info!(user_id = %user.user_id, "changed their password");
423 audit::Entry::new(audit::action::PASSWORD_CHANGED)
424 .by(user.user_id)
425 .by_email(&user.email)
426 .on(user.user_id)
427 .record(&state.pool)
428 .await;
429
430 Ok(StatusCode::NO_CONTENT)
431}
432
433fn require_manager_or_admin(user: &CurrentUser) -> Result<(), ApiError> {
435 match user.role {
436 UserRole::Admin | UserRole::Manager => Ok(()),
437 UserRole::Employee => Err(ApiError::new(StatusCode::FORBIDDEN, "not allowed")),
438 }
439}
440
441fn hash_new_password(password: &str) -> Result<String, ApiError> {
443 if password.chars().count() < MIN_PASSWORD_LENGTH {
444 return Err(ApiError::bad_request(format!("the password must be at least {MIN_PASSWORD_LENGTH} characters")));
445 }
446 session::hash_password(password).map_err(Into::into)
447}
448
449fn generate_token() -> String {
454 use rand::RngExt;
455
456 let bytes: [u8; 32] = rand::rng().random();
457 bytes.iter().fold(String::from("kasl_"), |mut acc, byte| {
458 use std::fmt::Write;
459 let _ = write!(acc, "{byte:02x}");
460 acc
461 })
462}
463
464fn looks_like_an_email(candidate: &str) -> bool {
470 match candidate.split_once('@') {
471 Some((local, domain)) => !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.'),
472 None => false,
473 }
474}
475
476async fn is_last_admin(pool: &sqlx::PgPool, target: Uuid) -> Result<bool, ApiError> {
478 let others: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE role = 'admin' AND active AND id <> $1")
479 .bind(target)
480 .fetch_one(pool)
481 .await?;
482
483 let is_admin: Option<bool> = sqlx::query_scalar("SELECT (role = 'admin' AND active) FROM users WHERE id = $1")
487 .bind(target)
488 .fetch_optional(pool)
489 .await?;
490
491 Ok(is_admin.unwrap_or(false) && others == 0)
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn a_token_is_long_random_and_recognisable() {
500 let token = generate_token();
501 assert!(token.starts_with("kasl_"), "a token in a log should be identifiable: {token}");
502 assert_eq!(token.len(), 5 + 64, "32 bytes as hex");
503 assert_ne!(token, generate_token(), "two tokens must never be the same");
504 }
505
506 #[test]
507 fn a_short_password_is_refused_before_it_is_hashed() {
508 let error = hash_new_password("short").expect_err("seven characters is not a password");
509 assert!(error.to_string().contains("at least 8"), "{error}");
510 assert!(hash_new_password("just long enough").is_ok());
511 }
512
513 #[test]
514 fn the_email_check_admits_addresses_and_refuses_obvious_mistakes() {
515 for good in ["a@b.co", "first.last@example.com", "kirill+kasl@example.co.uk"] {
516 assert!(looks_like_an_email(good), "{good} should be accepted");
517 }
518 for bad in ["kirill", "kirill@", "@example.com", "kirill@example", "kirill@example.com."] {
521 assert!(!looks_like_an_email(bad), "{bad} should be refused");
522 }
523 }
524
525 #[test]
526 fn a_manager_reads_and_an_employee_does_not() {
527 let user = |role| CurrentUser {
528 session_id: Uuid::nil(),
529 user_id: Uuid::nil(),
530 role,
531 email: "someone@example.test".to_string(),
532 };
533 assert!(require_manager_or_admin(&user(UserRole::Admin)).is_ok());
534 assert!(require_manager_or_admin(&user(UserRole::Manager)).is_ok());
535 assert!(require_manager_or_admin(&user(UserRole::Employee)).is_err());
536
537 assert!(user(UserRole::Manager).require_admin().is_err());
539 }
540
541 #[test]
542 fn an_absent_patch_field_means_leave_it_alone() {
543 let patch: UserPatch = serde_json::from_value(serde_json::json!({"display_name": "Kirill"})).unwrap();
546 assert_eq!(patch.display_name.as_deref(), Some("Kirill"));
547 assert!(patch.role.is_none() && patch.active.is_none() && patch.password.is_none());
548 }
549
550 #[test]
551 fn a_new_user_defaults_to_the_least_authority() {
552 let new: NewUser = serde_json::from_value(serde_json::json!({"email": "a@b.co"})).unwrap();
553 assert_eq!(new.role, UserRole::Employee, "a role must be asked for, never assumed");
554 assert!(new.password.is_none(), "an account for an agent needs no password");
555 }
556}