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, 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 created_at: DateTime<Utc>,
49}
50
51#[derive(Debug, Deserialize)]
52pub struct NewUser {
53 pub email: String,
54 pub display_name: Option<String>,
55 #[serde(default = "default_role")]
56 pub role: UserRole,
57 pub password: Option<String>,
60}
61
62fn default_role() -> UserRole {
63 UserRole::Employee
64}
65
66#[derive(Debug, Deserialize)]
69pub struct UserPatch {
70 pub display_name: Option<String>,
71 pub role: Option<UserRole>,
72 pub active: Option<bool>,
73 pub password: Option<String>,
77}
78
79#[derive(Debug, Serialize, sqlx::FromRow)]
81pub struct AgentRow {
82 pub id: Uuid,
83 pub name: String,
84 pub revoked_at: Option<DateTime<Utc>>,
85 pub last_seen_at: Option<DateTime<Utc>>,
86 pub created_at: DateTime<Utc>,
87}
88
89#[derive(Debug, Deserialize)]
90pub struct NewAgent {
91 pub name: String,
93}
94
95#[derive(Debug, Serialize)]
97pub struct IssuedAgent {
98 pub id: Uuid,
99 pub name: String,
100 pub token: String,
101 pub notice: &'static str,
104}
105
106pub async fn list_users(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
108 require_manager_or_admin(&user)?;
109
110 let users: Vec<UserRow> = sqlx::query_as(
111 "SELECT u.id, u.email, u.display_name, u.role, u.active,
112 (u.password_hash IS NOT NULL) AS has_password,
113 (SELECT count(*) FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS agents,
114 (SELECT max(a.last_seen_at) FROM agents a WHERE a.user_id = u.id) AS last_seen_at,
115 u.created_at
116 FROM users u
117 ORDER BY u.display_name, u.email",
118 )
119 .fetch_all(&state.pool)
120 .await?;
121
122 Ok(Json(users))
123}
124
125pub async fn create_user(State(state): State<AppState>, user: CurrentUser, Json(new): Json<NewUser>) -> Result<impl IntoResponse, ApiError> {
127 user.require_admin()?;
128
129 let email = new.email.trim();
130 if !looks_like_an_email(email) {
131 return Err(ApiError::bad_request("that does not look like an email address"));
132 }
133 let password_hash = match new.password.as_deref() {
134 Some(password) => Some(hash_new_password(password)?),
135 None => None,
136 };
137 let display_name = new
140 .display_name
141 .as_deref()
142 .map(str::trim)
143 .filter(|name| !name.is_empty())
144 .unwrap_or_else(|| email.split('@').next().unwrap_or(email));
145
146 let created: Result<Uuid, sqlx::Error> =
147 sqlx::query_scalar("INSERT INTO users (email, display_name, role, password_hash) VALUES ($1, $2, $3, $4) RETURNING id")
148 .bind(email)
149 .bind(display_name)
150 .bind(new.role)
151 .bind(password_hash.as_deref())
152 .fetch_one(&state.pool)
153 .await;
154
155 let id = match created {
156 Ok(id) => id,
157 Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
161 return Err(ApiError::new(StatusCode::CONFLICT, "someone with that email address already exists"));
162 }
163 Err(error) => return Err(error.into()),
164 };
165
166 tracing::info!(%id, by = %user.user_id, "created a user");
167 Ok((StatusCode::CREATED, Json(serde_json::json!({"id": id}))))
168}
169
170pub async fn update_user(
172 State(state): State<AppState>,
173 user: CurrentUser,
174 Path(target): Path<Uuid>,
175 Json(patch): Json<UserPatch>,
176) -> Result<impl IntoResponse, ApiError> {
177 user.require_admin()?;
178
179 let losing_admin = patch.role.is_some_and(|role| role != UserRole::Admin) || patch.active == Some(false);
184 if losing_admin && is_last_admin(&state.pool, target).await? {
185 return Err(ApiError::new(
186 StatusCode::CONFLICT,
187 "this is the only administrator; promote someone else first",
188 ));
189 }
190
191 let password_hash = match patch.password.as_deref() {
192 Some(password) => Some(hash_new_password(password)?),
193 None => None,
194 };
195
196 let updated = sqlx::query(
197 "UPDATE users SET
198 display_name = coalesce($2, display_name),
199 role = coalesce($3, role),
200 active = coalesce($4, active),
201 password_hash = coalesce($5, password_hash)
202 WHERE id = $1",
203 )
204 .bind(target)
205 .bind(patch.display_name.as_deref().map(str::trim).filter(|name| !name.is_empty()))
206 .bind(patch.role)
207 .bind(patch.active)
208 .bind(password_hash.as_deref())
209 .execute(&state.pool)
210 .await?
211 .rows_affected();
212
213 if updated == 0 {
214 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
215 }
216
217 if patch.active == Some(false) || patch.password.is_some() {
221 let ended = session::revoke_all(&state.pool, target).await?;
222 tracing::info!(%target, ended, "ended sessions after a change to the account");
223 }
224
225 tracing::info!(%target, by = %user.user_id, "updated a user");
226 Ok(StatusCode::NO_CONTENT)
227}
228
229pub async fn list_agents(State(state): State<AppState>, user: CurrentUser, Path(target): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
232 require_manager_or_admin(&user)?;
233
234 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")
235 .bind(target)
236 .fetch_all(&state.pool)
237 .await?;
238
239 Ok(Json(agents))
240}
241
242pub async fn create_agent(
244 State(state): State<AppState>,
245 user: CurrentUser,
246 Path(target): Path<Uuid>,
247 Json(new): Json<NewAgent>,
248) -> Result<impl IntoResponse, ApiError> {
249 user.require_admin()?;
250
251 let name = new.name.trim();
252 if name.is_empty() {
253 return Err(ApiError::bad_request("an agent needs a name; the machine it runs on is the usual one"));
254 }
255
256 let active: Option<bool> = sqlx::query_scalar("SELECT active FROM users WHERE id = $1")
260 .bind(target)
261 .fetch_optional(&state.pool)
262 .await?;
263 match active {
264 None => return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user")),
265 Some(false) => return Err(ApiError::new(StatusCode::CONFLICT, "that account is deactivated")),
266 Some(true) => {}
267 }
268
269 let token = generate_token();
270 let id: Uuid = sqlx::query_scalar("INSERT INTO agents (user_id, name, token_hash) VALUES ($1, $2, $3) RETURNING id")
271 .bind(target)
272 .bind(name)
273 .bind(hash_token(&token))
274 .fetch_one(&state.pool)
275 .await?;
276
277 tracing::info!(%id, %target, by = %user.user_id, "issued an agent token");
278 Ok((
279 StatusCode::CREATED,
280 Json(IssuedAgent {
281 id,
282 name: name.to_string(),
283 token,
284 notice: "this token is shown once; the server keeps only its hash",
285 }),
286 ))
287}
288
289pub async fn revoke_agent(State(state): State<AppState>, user: CurrentUser, Path(agent): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
291 user.require_admin()?;
292
293 let revoked = sqlx::query("UPDATE agents SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL")
297 .bind(agent)
298 .execute(&state.pool)
299 .await?
300 .rows_affected();
301
302 if revoked == 0 {
303 let exists: Option<Uuid> = sqlx::query_scalar("SELECT id FROM agents WHERE id = $1")
306 .bind(agent)
307 .fetch_optional(&state.pool)
308 .await?;
309 if exists.is_none() {
310 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such agent"));
311 }
312 }
313
314 tracing::info!(%agent, by = %user.user_id, "revoked an agent token");
315 Ok(StatusCode::NO_CONTENT)
316}
317
318#[derive(Debug, Deserialize)]
323pub struct PasswordChange {
324 pub current: String,
325 pub new: String,
326}
327
328pub async fn change_own_password(State(state): State<AppState>, user: CurrentUser, Json(change): Json<PasswordChange>) -> Result<impl IntoResponse, ApiError> {
329 let stored: Option<String> = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
330 .bind(user.user_id)
331 .fetch_one(&state.pool)
332 .await?;
333
334 let Some(stored) = stored else {
337 return Err(ApiError::new(StatusCode::CONFLICT, "this account has no password to change"));
338 };
339 if !session::verify_password(&change.current, &stored) {
340 return Err(ApiError::new(StatusCode::UNAUTHORIZED, "the current password is wrong"));
341 }
342
343 let hash = hash_new_password(&change.new)?;
344 sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
345 .bind(&hash)
346 .bind(user.user_id)
347 .execute(&state.pool)
348 .await?;
349
350 sqlx::query("DELETE FROM sessions WHERE user_id = $1 AND id <> $2")
354 .bind(user.user_id)
355 .bind(user.session_id)
356 .execute(&state.pool)
357 .await?;
358
359 tracing::info!(user_id = %user.user_id, "changed their password");
360 Ok(StatusCode::NO_CONTENT)
361}
362
363fn require_manager_or_admin(user: &CurrentUser) -> Result<(), ApiError> {
365 match user.role {
366 UserRole::Admin | UserRole::Manager => Ok(()),
367 UserRole::Employee => Err(ApiError::new(StatusCode::FORBIDDEN, "not allowed")),
368 }
369}
370
371fn hash_new_password(password: &str) -> Result<String, ApiError> {
373 if password.chars().count() < MIN_PASSWORD_LENGTH {
374 return Err(ApiError::bad_request(format!("the password must be at least {MIN_PASSWORD_LENGTH} characters")));
375 }
376 session::hash_password(password).map_err(Into::into)
377}
378
379fn generate_token() -> String {
384 use rand::RngExt;
385
386 let bytes: [u8; 32] = rand::rng().random();
387 bytes.iter().fold(String::from("kasl_"), |mut acc, byte| {
388 use std::fmt::Write;
389 let _ = write!(acc, "{byte:02x}");
390 acc
391 })
392}
393
394fn looks_like_an_email(candidate: &str) -> bool {
400 match candidate.split_once('@') {
401 Some((local, domain)) => !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.'),
402 None => false,
403 }
404}
405
406async fn is_last_admin(pool: &sqlx::PgPool, target: Uuid) -> Result<bool, ApiError> {
408 let others: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE role = 'admin' AND active AND id <> $1")
409 .bind(target)
410 .fetch_one(pool)
411 .await?;
412
413 let is_admin: Option<bool> = sqlx::query_scalar("SELECT (role = 'admin' AND active) FROM users WHERE id = $1")
417 .bind(target)
418 .fetch_optional(pool)
419 .await?;
420
421 Ok(is_admin.unwrap_or(false) && others == 0)
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn a_token_is_long_random_and_recognisable() {
430 let token = generate_token();
431 assert!(token.starts_with("kasl_"), "a token in a log should be identifiable: {token}");
432 assert_eq!(token.len(), 5 + 64, "32 bytes as hex");
433 assert_ne!(token, generate_token(), "two tokens must never be the same");
434 }
435
436 #[test]
437 fn a_short_password_is_refused_before_it_is_hashed() {
438 let error = hash_new_password("short").expect_err("seven characters is not a password");
439 assert!(error.to_string().contains("at least 8"), "{error}");
440 assert!(hash_new_password("just long enough").is_ok());
441 }
442
443 #[test]
444 fn the_email_check_admits_addresses_and_refuses_obvious_mistakes() {
445 for good in ["a@b.co", "first.last@example.com", "kirill+kasl@example.co.uk"] {
446 assert!(looks_like_an_email(good), "{good} should be accepted");
447 }
448 for bad in ["kirill", "kirill@", "@example.com", "kirill@example", "kirill@example.com."] {
451 assert!(!looks_like_an_email(bad), "{bad} should be refused");
452 }
453 }
454
455 #[test]
456 fn a_manager_reads_and_an_employee_does_not() {
457 let user = |role| CurrentUser {
458 session_id: Uuid::nil(),
459 user_id: Uuid::nil(),
460 role,
461 };
462 assert!(require_manager_or_admin(&user(UserRole::Admin)).is_ok());
463 assert!(require_manager_or_admin(&user(UserRole::Manager)).is_ok());
464 assert!(require_manager_or_admin(&user(UserRole::Employee)).is_err());
465
466 assert!(user(UserRole::Manager).require_admin().is_err());
468 }
469
470 #[test]
471 fn an_absent_patch_field_means_leave_it_alone() {
472 let patch: UserPatch = serde_json::from_value(serde_json::json!({"display_name": "Kirill"})).unwrap();
475 assert_eq!(patch.display_name.as_deref(), Some("Kirill"));
476 assert!(patch.role.is_none() && patch.active.is_none() && patch.password.is_none());
477 }
478
479 #[test]
480 fn a_new_user_defaults_to_the_least_authority() {
481 let new: NewUser = serde_json::from_value(serde_json::json!({"email": "a@b.co"})).unwrap();
482 assert_eq!(new.role, UserRole::Employee, "a role must be asked for, never assumed");
483 assert!(new.password.is_none(), "an account for an agent needs no password");
484 }
485}