1use serde_json::Value;
2use sqlx::Row;
3use sqlx::sqlite::SqliteRow;
4use tracing::{debug, info};
5use uuid::Uuid;
6
7use crate::sqlite::db::Database;
8use crate::sqlite::nonce::now_secs;
9use crate::sqlite::order::rfc3339;
10
11#[derive(Debug, Clone)]
28pub struct AdminUser {
29 pub id: String,
30 pub username: String,
33 pub password_hash: String,
35 pub status: String,
36 pub totp_secret: Option<Vec<u8>>,
39 pub totp_pending_secret: Option<Vec<u8>>,
41 pub totp_last_step: Option<i64>,
44 pub created_at: i64,
45 pub updated_at: i64,
46 pub last_login_at: Option<i64>,
47}
48
49macro_rules! columns {
55 () => {
56 "id, username, password_hash, status, totp_secret, totp_pending_secret, \
57 totp_last_step, created_at, updated_at, last_login_at"
58 };
59}
60
61impl AdminUser {
62 fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
63 Ok(AdminUser {
64 id: row.try_get("id")?,
65 username: row.try_get("username")?,
66 password_hash: row.try_get("password_hash")?,
67 status: row.try_get("status")?,
68 totp_secret: row.try_get("totp_secret")?,
69 totp_pending_secret: row.try_get("totp_pending_secret")?,
70 totp_last_step: row.try_get("totp_last_step")?,
71 created_at: row.try_get("created_at")?,
72 updated_at: row.try_get("updated_at")?,
73 last_login_at: row.try_get("last_login_at")?,
74 })
75 }
76
77 pub async fn create(
87 username: &str,
88 password_hash: &str,
89 database: &Database,
90 ) -> Result<AdminUser, sqlx::Error> {
91 let now = now_secs();
92 let user = AdminUser {
93 id: Uuid::new_v4().to_string(),
94 username: username.trim().to_lowercase(),
95 password_hash: password_hash.to_string(),
96 status: "active".to_string(),
97 totp_secret: None,
98 totp_pending_secret: None,
99 totp_last_step: None,
100 created_at: now,
101 updated_at: now,
102 last_login_at: None,
103 };
104
105 debug!(event = "db_admin_user_create_started", outcome = "progress", username = %user.username);
106 sqlx::query(
107 "INSERT INTO admin_users (id, username, password_hash, status, created_at, updated_at) \
108 VALUES (?, ?, ?, ?, ?, ?);",
109 )
110 .bind(&user.id)
111 .bind(&user.username)
112 .bind(&user.password_hash)
113 .bind(&user.status)
114 .bind(user.created_at)
115 .bind(user.updated_at)
116 .execute(&database.pool)
117 .await?;
118
119 info!(event = "db_admin_user_created", outcome = "success", user_id = %user.id, username = %user.username);
120 Ok(user)
121 }
122
123 pub async fn find_by_id(
124 id: &str,
125 database: &Database,
126 ) -> Result<Option<AdminUser>, sqlx::Error> {
127 debug!(event = "db_admin_user_find_by_id_started", outcome = "progress", id = ?id);
128 let row = sqlx::query(concat!(
129 "SELECT ",
130 columns!(),
131 " FROM admin_users WHERE id = ?;"
132 ))
133 .bind(id)
134 .fetch_optional(&database.pool)
135 .await?;
136
137 row.map(AdminUser::from_row).transpose()
138 }
139
140 pub async fn find_by_username(
143 username: &str,
144 database: &Database,
145 ) -> Result<Option<AdminUser>, sqlx::Error> {
146 debug!(
147 event = "db_admin_user_find_by_username_started",
148 outcome = "progress"
149 );
150 let row = sqlx::query(concat!(
151 "SELECT ",
152 columns!(),
153 " FROM admin_users WHERE username = ?;"
154 ))
155 .bind(username.trim().to_lowercase())
156 .fetch_optional(&database.pool)
157 .await?;
158
159 row.map(AdminUser::from_row).transpose()
160 }
161
162 pub async fn list_all(database: &Database) -> Result<Vec<AdminUser>, sqlx::Error> {
164 debug!(
165 event = "db_admin_user_list_all_started",
166 outcome = "progress"
167 );
168 let rows = sqlx::query(concat!(
169 "SELECT ",
170 columns!(),
171 " FROM admin_users ORDER BY created_at ASC, id ASC;"
172 ))
173 .fetch_all(&database.pool)
174 .await?;
175
176 rows.into_iter().map(AdminUser::from_row).collect()
177 }
178
179 pub async fn set_password_hash(
183 &mut self,
184 password_hash: &str,
185 database: &Database,
186 ) -> Result<(), sqlx::Error> {
187 let now = now_secs();
188 sqlx::query("UPDATE admin_users SET password_hash = ?, updated_at = ? WHERE id = ?;")
189 .bind(password_hash)
190 .bind(now)
191 .bind(&self.id)
192 .execute(&database.pool)
193 .await?;
194
195 self.password_hash = password_hash.to_string();
196 self.updated_at = now;
197 info!(event = "db_admin_user_password_changed", outcome = "success", user_id = %self.id, username = %self.username);
198 Ok(())
199 }
200
201 pub async fn set_status(
206 &mut self,
207 status: &str,
208 database: &Database,
209 ) -> Result<(), sqlx::Error> {
210 let now = now_secs();
211 sqlx::query("UPDATE admin_users SET status = ?, updated_at = ? WHERE id = ?;")
212 .bind(status)
213 .bind(now)
214 .bind(&self.id)
215 .execute(&database.pool)
216 .await?;
217
218 self.status = status.to_string();
219 self.updated_at = now;
220 info!(event = "db_admin_user_status_changed", outcome = "success", user_id = %self.id, username = %self.username, status = %status);
221 Ok(())
222 }
223
224 pub async fn set_totp_pending(
230 &mut self,
231 secret: &[u8],
232 database: &Database,
233 ) -> Result<(), sqlx::Error> {
234 let now = now_secs();
235 sqlx::query("UPDATE admin_users SET totp_pending_secret = ?, updated_at = ? WHERE id = ?;")
236 .bind(secret)
237 .bind(now)
238 .bind(&self.id)
239 .execute(&database.pool)
240 .await?;
241
242 self.totp_pending_secret = Some(secret.to_vec());
243 self.updated_at = now;
244 info!(event = "db_admin_totp_enrolment_started", outcome = "progress", user_id = %self.id, username = %self.username);
245 Ok(())
246 }
247
248 pub async fn confirm_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
258 let Some(pending) = self.totp_pending_secret.clone() else {
259 return Ok(());
260 };
261
262 let now = now_secs();
263 sqlx::query(
264 "UPDATE admin_users SET totp_secret = totp_pending_secret, \
265 totp_pending_secret = NULL, totp_last_step = NULL, updated_at = ? \
266 WHERE id = ? AND totp_pending_secret IS NOT NULL;",
267 )
268 .bind(now)
269 .bind(&self.id)
270 .execute(&database.pool)
271 .await?;
272
273 self.totp_secret = Some(pending);
274 self.totp_pending_secret = None;
275 self.totp_last_step = None;
276 self.updated_at = now;
277 info!(event = "db_admin_totp_enabled", outcome = "success", user_id = %self.id, username = %self.username);
278 Ok(())
279 }
280
281 pub async fn clear_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
285 let now = now_secs();
286 sqlx::query(
287 "UPDATE admin_users SET totp_secret = NULL, totp_pending_secret = NULL, \
288 totp_last_step = NULL, updated_at = ? WHERE id = ?;",
289 )
290 .bind(now)
291 .bind(&self.id)
292 .execute(&database.pool)
293 .await?;
294
295 self.totp_secret = None;
296 self.totp_pending_secret = None;
297 self.totp_last_step = None;
298 self.updated_at = now;
299 info!(event = "db_admin_totp_disabled", outcome = "success", user_id = %self.id, username = %self.username);
300 Ok(())
301 }
302
303 pub async fn claim_totp_step(
312 &mut self,
313 step: i64,
314 database: &Database,
315 ) -> Result<bool, sqlx::Error> {
316 let now = now_secs();
317 let result = sqlx::query(
318 "UPDATE admin_users SET totp_last_step = ?, updated_at = ? \
319 WHERE id = ? AND (totp_last_step IS NULL OR totp_last_step < ?);",
320 )
321 .bind(step)
322 .bind(now)
323 .bind(&self.id)
324 .bind(step)
325 .execute(&database.pool)
326 .await?;
327
328 let claimed = result.rows_affected() == 1;
329 if claimed {
330 self.totp_last_step = Some(step);
331 self.updated_at = now;
332 }
333 Ok(claimed)
334 }
335
336 pub async fn mark_logged_in(&mut self, database: &Database) -> Result<(), sqlx::Error> {
341 let now = now_secs();
342 sqlx::query("UPDATE admin_users SET last_login_at = ? WHERE id = ?;")
343 .bind(now)
344 .bind(&self.id)
345 .execute(&database.pool)
346 .await?;
347
348 self.last_login_at = Some(now);
349 Ok(())
350 }
351
352 pub async fn delete(id: &str, database: &Database) -> Result<bool, sqlx::Error> {
356 debug!(event = "db_admin_user_delete_started", outcome = "progress", id = ?id);
357 let result = sqlx::query("DELETE FROM admin_users WHERE id = ?;")
358 .bind(id)
359 .execute(&database.pool)
360 .await?;
361
362 let deleted = result.rows_affected() > 0;
363 if deleted {
364 info!(event = "db_admin_user_deleted", outcome = "success", user_id = %id);
365 }
366 Ok(deleted)
367 }
368
369 #[must_use]
371 pub fn is_active(&self) -> bool {
372 self.status == "active"
373 }
374
375 #[must_use]
378 pub fn has_totp(&self) -> bool {
379 self.totp_secret.is_some()
380 }
381
382 #[must_use]
390 pub fn has_pending_totp(&self) -> bool {
391 self.totp_pending_secret.is_some()
392 }
393
394 #[must_use]
397 pub fn to_json(&self) -> Value {
398 serde_json::json!({
399 "id": self.id,
400 "username": self.username,
401 "status": self.status,
402 "totpEnabled": self.has_totp(),
403 "createdAt": rfc3339(self.created_at),
404 "updatedAt": rfc3339(self.updated_at),
405 "lastLoginAt": self.last_login_at.map(rfc3339),
406 })
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use std::sync::Arc;
414
415 async fn db() -> Arc<Database> {
416 Arc::new(Database::connect_in_memory().await.unwrap())
417 }
418
419 #[tokio::test]
420 async fn create_persists_an_active_user_with_a_lowercased_username() {
421 let db = db().await;
422 let user = AdminUser::create(" Alice ", "hash", &db).await.unwrap();
423 assert_eq!(user.username, "alice");
424 assert_eq!(user.status, "active");
425 assert!(user.is_active());
426 assert!(user.last_login_at.is_none());
427 assert!(!user.has_totp());
428 }
429
430 #[tokio::test]
431 async fn find_by_username_is_case_insensitive_and_round_trips() {
432 let db = db().await;
433 let created = AdminUser::create("alice", "hash", &db).await.unwrap();
434 let found = AdminUser::find_by_username("ALICE", &db)
435 .await
436 .unwrap()
437 .unwrap();
438 assert_eq!(found.id, created.id);
439 assert_eq!(found.password_hash, "hash");
440
441 let by_id = AdminUser::find_by_id(&created.id, &db)
442 .await
443 .unwrap()
444 .unwrap();
445 assert_eq!(by_id.username, "alice");
446 }
447
448 #[tokio::test]
449 async fn lookups_of_unknown_users_return_none() {
450 let db = db().await;
451 assert!(
452 AdminUser::find_by_username("nobody", &db)
453 .await
454 .unwrap()
455 .is_none()
456 );
457 assert!(AdminUser::find_by_id("nope", &db).await.unwrap().is_none());
458 }
459
460 #[tokio::test]
461 async fn a_duplicate_username_is_refused_by_the_unique_constraint() {
462 let db = db().await;
463 AdminUser::create("alice", "hash", &db).await.unwrap();
464 let error = AdminUser::create("Alice", "other", &db).await.unwrap_err();
467 assert!(
468 error.to_string().to_lowercase().contains("unique"),
469 "expected a UNIQUE violation, got: {error}"
470 );
471 }
472
473 #[tokio::test]
474 async fn list_all_returns_every_user_and_empty_is_empty() {
475 let db = db().await;
476 assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
477
478 AdminUser::create("a", "h", &db).await.unwrap();
479 AdminUser::create("b", "h", &db).await.unwrap();
480 let all = AdminUser::list_all(&db).await.unwrap();
481 assert_eq!(all.len(), 2);
482 let mut names: Vec<&str> = all.iter().map(|u| u.username.as_str()).collect();
488 names.sort_unstable();
489 assert_eq!(names, ["a", "b"]);
490 }
491
492 #[tokio::test]
493 async fn list_all_orders_oldest_first() {
494 let db = db().await;
495 let older = AdminUser::create("older", "h", &db).await.unwrap();
496 let newer = AdminUser::create("newer", "h", &db).await.unwrap();
497 sqlx::query("UPDATE admin_users SET created_at = ? WHERE id = ?;")
500 .bind(older.created_at - 60)
501 .bind(&older.id)
502 .execute(&db.pool)
503 .await
504 .unwrap();
505
506 let all = AdminUser::list_all(&db).await.unwrap();
507 assert_eq!(all[0].id, older.id);
508 assert_eq!(all[1].id, newer.id);
509 }
510
511 #[tokio::test]
512 async fn set_password_hash_persists_and_syncs_in_memory() {
513 let db = db().await;
514 let mut user = AdminUser::create("alice", "old", &db).await.unwrap();
515 user.set_password_hash("new", &db).await.unwrap();
516 assert_eq!(user.password_hash, "new");
517
518 let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
519 assert_eq!(reloaded.password_hash, "new");
520 }
521
522 #[tokio::test]
523 async fn set_status_persists_and_disables() {
524 let db = db().await;
525 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
526 user.set_status("disabled", &db).await.unwrap();
527 assert!(!user.is_active());
528
529 let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
530 assert!(!reloaded.is_active());
531 }
532
533 #[tokio::test]
534 async fn the_totp_setters_persist_and_sync_in_memory() {
535 let db = db().await;
536 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
537
538 user.set_totp_pending(b"secret-bytes", &db).await.unwrap();
539 assert!(user.has_pending_totp());
540 assert!(
541 !user.has_totp(),
542 "a pending enrolment must not read as a second factor"
543 );
544 let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
545 assert_eq!(
546 reloaded.totp_pending_secret.as_deref(),
547 Some(&b"secret-bytes"[..])
548 );
549 assert!(!reloaded.has_totp());
550
551 user.confirm_totp(&db).await.unwrap();
552 assert!(user.has_totp());
553 assert!(!user.has_pending_totp());
554 let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
555 assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"secret-bytes"[..]));
556 assert_eq!(reloaded.totp_pending_secret, None);
557
558 user.clear_totp(&db).await.unwrap();
559 let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
560 assert_eq!(reloaded.totp_secret, None);
561 assert_eq!(reloaded.totp_pending_secret, None);
562 assert_eq!(reloaded.totp_last_step, None);
563 }
564
565 #[tokio::test]
568 async fn confirming_with_nothing_pending_leaves_a_live_factor_alone() {
569 let db = db().await;
570 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
571 user.set_totp_pending(b"live", &db).await.unwrap();
572 user.confirm_totp(&db).await.unwrap();
573
574 user.confirm_totp(&db).await.unwrap();
575
576 assert!(user.has_totp());
577 let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
578 assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"live"[..]));
579 }
580
581 #[tokio::test]
584 async fn claim_totp_step_refuses_a_step_it_has_already_seen() {
585 let db = db().await;
586 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
587
588 assert!(user.claim_totp_step(100, &db).await.unwrap());
589 assert_eq!(user.totp_last_step, Some(100));
590
591 assert!(!user.claim_totp_step(100, &db).await.unwrap());
593 assert!(!user.claim_totp_step(99, &db).await.unwrap());
594 assert_eq!(
595 user.totp_last_step,
596 Some(100),
597 "a refused claim must not move the guard"
598 );
599
600 assert!(user.claim_totp_step(101, &db).await.unwrap());
602 let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
603 assert_eq!(reloaded.totp_last_step, Some(101));
604 }
605
606 #[tokio::test]
607 async fn the_status_check_refuses_a_value_outside_the_schema() {
608 let db = db().await;
609 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
610 assert!(user.set_status("banished", &db).await.is_err());
611 }
612
613 #[tokio::test]
614 async fn mark_logged_in_stamps_last_login_at() {
615 let db = db().await;
616 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
617 user.mark_logged_in(&db).await.unwrap();
618 assert!(user.last_login_at.is_some());
619
620 let reloaded = AdminUser::find_by_id(&user.id, &db).await.unwrap().unwrap();
621 assert_eq!(reloaded.last_login_at, user.last_login_at);
622 }
623
624 #[tokio::test]
625 async fn delete_reports_whether_a_row_existed() {
626 let db = db().await;
627 let user = AdminUser::create("alice", "h", &db).await.unwrap();
628 assert!(AdminUser::delete(&user.id, &db).await.unwrap());
629 assert!(!AdminUser::delete(&user.id, &db).await.unwrap());
630 }
631
632 #[tokio::test]
633 async fn to_json_never_leaks_the_hash_or_the_totp_secret() {
634 let db = db().await;
635 let user = AdminUser::create("alice", "super-secret-hash", &db)
636 .await
637 .unwrap();
638 let json = user.to_json();
639 assert!(json.get("password_hash").is_none());
640 assert!(json.get("passwordHash").is_none());
641 assert!(json.get("totpSecret").is_none());
642 assert!(!json.to_string().contains("super-secret-hash"));
643 assert_eq!(json["username"], "alice");
644 assert_eq!(json["status"], "active");
645 assert_eq!(json["totpEnabled"], false);
646 assert_eq!(json["lastLoginAt"], Value::Null);
647 }
648}