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: Uuid,
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: crate::sqlite::id::mint(),
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: Uuid,
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> {
172 debug!(
173 event = "db_admin_user_list_all_started",
174 outcome = "progress"
175 );
176 let rows = sqlx::query(concat!(
177 "SELECT ",
178 columns!(),
179 " FROM admin_users ORDER BY created_at ASC, id ASC;"
180 ))
181 .fetch_all(&database.pool)
182 .await?;
183
184 rows.into_iter().map(AdminUser::from_row).collect()
185 }
186
187 pub async fn search(
196 limit: i64,
197 offset: i64,
198 database: &Database,
199 ) -> Result<(Vec<AdminUser>, i64), sqlx::Error> {
200 debug!(
201 event = "db_admin_user_search_started",
202 outcome = "progress",
203 limit = limit,
204 offset = offset
205 );
206 let rows = sqlx::query(concat!(
207 "SELECT ",
208 columns!(),
209 " FROM admin_users ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?;"
210 ))
211 .bind(limit)
212 .bind(offset)
213 .fetch_all(&database.pool)
214 .await?;
215 let total: i64 = sqlx::query("SELECT COUNT(*) FROM admin_users;")
216 .fetch_one(&database.pool)
217 .await?
218 .try_get(0)?;
219
220 let users = rows
221 .into_iter()
222 .map(AdminUser::from_row)
223 .collect::<Result<_, _>>()?;
224 Ok((users, total))
225 }
226
227 pub async fn set_password_hash(
231 &mut self,
232 password_hash: &str,
233 database: &Database,
234 ) -> Result<(), sqlx::Error> {
235 let now = now_secs();
236 sqlx::query("UPDATE admin_users SET password_hash = ?, updated_at = ? WHERE id = ?;")
237 .bind(password_hash)
238 .bind(now)
239 .bind(self.id)
240 .execute(&database.pool)
241 .await?;
242
243 self.password_hash = password_hash.to_string();
244 self.updated_at = now;
245 info!(event = "db_admin_user_password_changed", outcome = "success", user_id = %self.id, username = %self.username);
246 Ok(())
247 }
248
249 pub async fn set_status(
254 &mut self,
255 status: &str,
256 database: &Database,
257 ) -> Result<(), sqlx::Error> {
258 let now = now_secs();
259 sqlx::query("UPDATE admin_users SET status = ?, updated_at = ? WHERE id = ?;")
260 .bind(status)
261 .bind(now)
262 .bind(self.id)
263 .execute(&database.pool)
264 .await?;
265
266 self.status = status.to_string();
267 self.updated_at = now;
268 info!(event = "db_admin_user_status_changed", outcome = "success", user_id = %self.id, username = %self.username, status = %status);
269 Ok(())
270 }
271
272 pub async fn set_totp_pending(
278 &mut self,
279 secret: &[u8],
280 database: &Database,
281 ) -> Result<(), sqlx::Error> {
282 let now = now_secs();
283 sqlx::query("UPDATE admin_users SET totp_pending_secret = ?, updated_at = ? WHERE id = ?;")
284 .bind(secret)
285 .bind(now)
286 .bind(self.id)
287 .execute(&database.pool)
288 .await?;
289
290 self.totp_pending_secret = Some(secret.to_vec());
291 self.updated_at = now;
292 info!(event = "db_admin_totp_enrolment_started", outcome = "progress", user_id = %self.id, username = %self.username);
293 Ok(())
294 }
295
296 pub async fn confirm_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
306 let Some(pending) = self.totp_pending_secret.clone() else {
307 return Ok(());
308 };
309
310 let now = now_secs();
311 sqlx::query(
312 "UPDATE admin_users SET totp_secret = totp_pending_secret, \
313 totp_pending_secret = NULL, totp_last_step = NULL, updated_at = ? \
314 WHERE id = ? AND totp_pending_secret IS NOT NULL;",
315 )
316 .bind(now)
317 .bind(self.id)
318 .execute(&database.pool)
319 .await?;
320
321 self.totp_secret = Some(pending);
322 self.totp_pending_secret = None;
323 self.totp_last_step = None;
324 self.updated_at = now;
325 info!(event = "db_admin_totp_enabled", outcome = "success", user_id = %self.id, username = %self.username);
326 Ok(())
327 }
328
329 pub async fn clear_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
333 let now = now_secs();
334 sqlx::query(
335 "UPDATE admin_users SET totp_secret = NULL, totp_pending_secret = NULL, \
336 totp_last_step = NULL, updated_at = ? WHERE id = ?;",
337 )
338 .bind(now)
339 .bind(self.id)
340 .execute(&database.pool)
341 .await?;
342
343 self.totp_secret = None;
344 self.totp_pending_secret = None;
345 self.totp_last_step = None;
346 self.updated_at = now;
347 info!(event = "db_admin_totp_disabled", outcome = "success", user_id = %self.id, username = %self.username);
348 Ok(())
349 }
350
351 pub async fn claim_totp_step(
360 &mut self,
361 step: i64,
362 database: &Database,
363 ) -> Result<bool, sqlx::Error> {
364 let now = now_secs();
365 let result = sqlx::query(
366 "UPDATE admin_users SET totp_last_step = ?, updated_at = ? \
367 WHERE id = ? AND (totp_last_step IS NULL OR totp_last_step < ?);",
368 )
369 .bind(step)
370 .bind(now)
371 .bind(self.id)
372 .bind(step)
373 .execute(&database.pool)
374 .await?;
375
376 let claimed = result.rows_affected() == 1;
377 if claimed {
378 self.totp_last_step = Some(step);
379 self.updated_at = now;
380 }
381 Ok(claimed)
382 }
383
384 pub async fn mark_logged_in(&mut self, database: &Database) -> Result<(), sqlx::Error> {
389 let now = now_secs();
390 sqlx::query("UPDATE admin_users SET last_login_at = ? WHERE id = ?;")
391 .bind(now)
392 .bind(self.id)
393 .execute(&database.pool)
394 .await?;
395
396 self.last_login_at = Some(now);
397 Ok(())
398 }
399
400 pub async fn delete(id: Uuid, database: &Database) -> Result<bool, sqlx::Error> {
404 debug!(event = "db_admin_user_delete_started", outcome = "progress", id = ?id);
405 let result = sqlx::query("DELETE FROM admin_users WHERE id = ?;")
406 .bind(id)
407 .execute(&database.pool)
408 .await?;
409
410 let deleted = result.rows_affected() > 0;
411 if deleted {
412 info!(event = "db_admin_user_deleted", outcome = "success", user_id = %id);
413 }
414 Ok(deleted)
415 }
416
417 #[must_use]
419 pub fn is_active(&self) -> bool {
420 self.status == "active"
421 }
422
423 #[must_use]
426 pub fn has_totp(&self) -> bool {
427 self.totp_secret.is_some()
428 }
429
430 #[must_use]
438 pub fn has_pending_totp(&self) -> bool {
439 self.totp_pending_secret.is_some()
440 }
441
442 #[must_use]
445 pub fn to_json(&self) -> Value {
446 serde_json::json!({
447 "id": self.id,
448 "username": self.username,
449 "status": self.status,
450 "totpEnabled": self.has_totp(),
451 "createdAt": rfc3339(self.created_at),
452 "updatedAt": rfc3339(self.updated_at),
453 "lastLoginAt": self.last_login_at.map(rfc3339),
454 })
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use std::sync::Arc;
462
463 async fn db() -> Arc<Database> {
464 Arc::new(Database::connect_in_memory().await.unwrap())
465 }
466
467 #[tokio::test]
468 async fn create_persists_an_active_user_with_a_lowercased_username() {
469 let db = db().await;
470 let user = AdminUser::create(" Alice ", "hash", &db).await.unwrap();
471 assert_eq!(user.username, "alice");
472 assert_eq!(user.status, "active");
473 assert!(user.is_active());
474 assert!(user.last_login_at.is_none());
475 assert!(!user.has_totp());
476 }
477
478 #[tokio::test]
479 async fn find_by_username_is_case_insensitive_and_round_trips() {
480 let db = db().await;
481 let created = AdminUser::create("alice", "hash", &db).await.unwrap();
482 let found = AdminUser::find_by_username("ALICE", &db)
483 .await
484 .unwrap()
485 .unwrap();
486 assert_eq!(found.id, created.id);
487 assert_eq!(found.password_hash, "hash");
488
489 let by_id = AdminUser::find_by_id(created.id, &db)
490 .await
491 .unwrap()
492 .unwrap();
493 assert_eq!(by_id.username, "alice");
494 }
495
496 #[tokio::test]
497 async fn lookups_of_unknown_users_return_none() {
498 let db = db().await;
499 assert!(
500 AdminUser::find_by_username("nobody", &db)
501 .await
502 .unwrap()
503 .is_none()
504 );
505 assert!(
506 AdminUser::find_by_id(crate::sqlite::id::mint(), &db)
507 .await
508 .unwrap()
509 .is_none()
510 );
511 }
512
513 #[tokio::test]
514 async fn a_duplicate_username_is_refused_by_the_unique_constraint() {
515 let db = db().await;
516 AdminUser::create("alice", "hash", &db).await.unwrap();
517 let error = AdminUser::create("Alice", "other", &db).await.unwrap_err();
520 assert!(
521 error.to_string().to_lowercase().contains("unique"),
522 "expected a UNIQUE violation, got: {error}"
523 );
524 }
525
526 #[tokio::test]
527 async fn list_all_returns_every_user_and_empty_is_empty() {
528 let db = db().await;
529 assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
530
531 AdminUser::create("a", "h", &db).await.unwrap();
532 AdminUser::create("b", "h", &db).await.unwrap();
533 let all = AdminUser::list_all(&db).await.unwrap();
534 assert_eq!(all.len(), 2);
535 let names: Vec<&str> = all.iter().map(|u| u.username.as_str()).collect();
544 assert_eq!(names, ["a", "b"], "the v7 tiebreak is insertion order");
545 }
546
547 #[tokio::test]
550 async fn search_pages_without_overlap_and_reports_the_unpaged_total() {
551 let db = db().await;
552 assert_eq!(AdminUser::search(50, 0, &db).await.unwrap().1, 0);
553
554 for name in ["a", "b", "c", "d", "e"] {
555 AdminUser::create(name, "h", &db).await.unwrap();
556 }
557
558 let (first, total) = AdminUser::search(2, 0, &db).await.unwrap();
559 let (second, also_total) = AdminUser::search(2, 2, &db).await.unwrap();
560 let (third, _) = AdminUser::search(2, 4, &db).await.unwrap();
561
562 assert_eq!((total, also_total), (5, 5), "the total is the table");
563 assert_eq!((first.len(), second.len(), third.len()), (2, 2, 1));
564
565 let walked: Vec<&str> = first
570 .iter()
571 .chain(second.iter())
572 .chain(third.iter())
573 .map(|user| user.username.as_str())
574 .collect();
575 assert_eq!(walked, ["a", "b", "c", "d", "e"]);
576 }
577
578 #[tokio::test]
582 async fn search_reads_the_table_in_the_same_order_as_the_scan() {
583 let db = db().await;
584 for name in ["a", "b", "c"] {
585 AdminUser::create(name, "h", &db).await.unwrap();
586 }
587
588 let scanned: Vec<String> = AdminUser::list_all(&db)
589 .await
590 .unwrap()
591 .into_iter()
592 .map(|user| user.username)
593 .collect();
594 let paged: Vec<String> = AdminUser::search(50, 0, &db)
595 .await
596 .unwrap()
597 .0
598 .into_iter()
599 .map(|user| user.username)
600 .collect();
601 assert_eq!(paged, scanned);
602 }
603
604 #[tokio::test]
605 async fn list_all_orders_oldest_first() {
606 let db = db().await;
607 let older = AdminUser::create("older", "h", &db).await.unwrap();
608 let newer = AdminUser::create("newer", "h", &db).await.unwrap();
609 sqlx::query("UPDATE admin_users SET created_at = ? WHERE id = ?;")
612 .bind(older.created_at - 60)
613 .bind(older.id)
614 .execute(&db.pool)
615 .await
616 .unwrap();
617
618 let all = AdminUser::list_all(&db).await.unwrap();
619 assert_eq!(all[0].id, older.id);
620 assert_eq!(all[1].id, newer.id);
621 }
622
623 #[tokio::test]
624 async fn set_password_hash_persists_and_syncs_in_memory() {
625 let db = db().await;
626 let mut user = AdminUser::create("alice", "old", &db).await.unwrap();
627 user.set_password_hash("new", &db).await.unwrap();
628 assert_eq!(user.password_hash, "new");
629
630 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
631 assert_eq!(reloaded.password_hash, "new");
632 }
633
634 #[tokio::test]
635 async fn set_status_persists_and_disables() {
636 let db = db().await;
637 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
638 user.set_status("disabled", &db).await.unwrap();
639 assert!(!user.is_active());
640
641 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
642 assert!(!reloaded.is_active());
643 }
644
645 #[tokio::test]
646 async fn the_totp_setters_persist_and_sync_in_memory() {
647 let db = db().await;
648 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
649
650 user.set_totp_pending(b"secret-bytes", &db).await.unwrap();
651 assert!(user.has_pending_totp());
652 assert!(
653 !user.has_totp(),
654 "a pending enrolment must not read as a second factor"
655 );
656 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
657 assert_eq!(
658 reloaded.totp_pending_secret.as_deref(),
659 Some(&b"secret-bytes"[..])
660 );
661 assert!(!reloaded.has_totp());
662
663 user.confirm_totp(&db).await.unwrap();
664 assert!(user.has_totp());
665 assert!(!user.has_pending_totp());
666 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
667 assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"secret-bytes"[..]));
668 assert_eq!(reloaded.totp_pending_secret, None);
669
670 user.clear_totp(&db).await.unwrap();
671 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
672 assert_eq!(reloaded.totp_secret, None);
673 assert_eq!(reloaded.totp_pending_secret, None);
674 assert_eq!(reloaded.totp_last_step, None);
675 }
676
677 #[tokio::test]
680 async fn confirming_with_nothing_pending_leaves_a_live_factor_alone() {
681 let db = db().await;
682 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
683 user.set_totp_pending(b"live", &db).await.unwrap();
684 user.confirm_totp(&db).await.unwrap();
685
686 user.confirm_totp(&db).await.unwrap();
687
688 assert!(user.has_totp());
689 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
690 assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"live"[..]));
691 }
692
693 #[tokio::test]
696 async fn claim_totp_step_refuses_a_step_it_has_already_seen() {
697 let db = db().await;
698 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
699
700 assert!(user.claim_totp_step(100, &db).await.unwrap());
701 assert_eq!(user.totp_last_step, Some(100));
702
703 assert!(!user.claim_totp_step(100, &db).await.unwrap());
705 assert!(!user.claim_totp_step(99, &db).await.unwrap());
706 assert_eq!(
707 user.totp_last_step,
708 Some(100),
709 "a refused claim must not move the guard"
710 );
711
712 assert!(user.claim_totp_step(101, &db).await.unwrap());
714 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
715 assert_eq!(reloaded.totp_last_step, Some(101));
716 }
717
718 #[tokio::test]
719 async fn the_status_check_refuses_a_value_outside_the_schema() {
720 let db = db().await;
721 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
722 assert!(user.set_status("banished", &db).await.is_err());
723 }
724
725 #[tokio::test]
726 async fn mark_logged_in_stamps_last_login_at() {
727 let db = db().await;
728 let mut user = AdminUser::create("alice", "h", &db).await.unwrap();
729 user.mark_logged_in(&db).await.unwrap();
730 assert!(user.last_login_at.is_some());
731
732 let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
733 assert_eq!(reloaded.last_login_at, user.last_login_at);
734 }
735
736 #[tokio::test]
737 async fn delete_reports_whether_a_row_existed() {
738 let db = db().await;
739 let user = AdminUser::create("alice", "h", &db).await.unwrap();
740 assert!(AdminUser::delete(user.id, &db).await.unwrap());
741 assert!(!AdminUser::delete(user.id, &db).await.unwrap());
742 }
743
744 #[tokio::test]
745 async fn to_json_never_leaks_the_hash_or_the_totp_secret() {
746 let db = db().await;
747 let user = AdminUser::create("alice", "super-secret-hash", &db)
748 .await
749 .unwrap();
750 let json = user.to_json();
751 assert!(json.get("password_hash").is_none());
752 assert!(json.get("passwordHash").is_none());
753 assert!(json.get("totpSecret").is_none());
754 assert!(!json.to_string().contains("super-secret-hash"));
755 assert_eq!(json["username"], "alice");
756 assert_eq!(json["status"], "active");
757 assert_eq!(json["totpEnabled"], false);
758 assert_eq!(json["lastLoginAt"], Value::Null);
759 }
760}