Skip to main content

acme_proxy_store/
admin_user.rs

1//! The web admin's operators — the `admin_users` table — and their privilege
2//! tier, [`AdminRole`].
3//!
4//! An operator is a person, not an ACME client, and is scoped to no profile.
5//! The traps: usernames are lowercased on the way in; a `NULL` role reads as
6//! [`AdminRole::Admin`] so a row older than the column keeps its authority; and
7//! accepting a TOTP code is a guarded `UPDATE` on the last step used
8//! ([`AdminUser::claim_totp_step`]), so a code cannot be replayed inside its
9//! own window.
10
11use std::str::FromStr;
12
13use crate::sql::Row;
14use serde_json::Value;
15use tracing::{debug, info};
16use uuid::Uuid;
17
18use crate::db::Database;
19use crate::nonce::now_secs;
20use acme_proxy_core::datetime::rfc3339;
21
22/// What a web-admin operator's live sessions are allowed to do.
23///
24/// A privilege tier, not an authentication state (that is `admin_sessions.state`
25/// and the `pending_mfa` / `active` split). Stored in `admin_users.role` as one
26/// of the [`AdminRole::as_str`] spellings, with **`NULL` read as [`Admin`]** --
27/// an operator created before the column existed keeps the authority they had,
28/// and so does the bootstrap operator, who is the only way into the panel.
29///
30/// Variants are declared low privilege to high, so `role >= AdminRole::Operator`
31/// is the gate the write extractors run (`crates/admin/src/webadmin/session.rs`).
32///
33/// [`Admin`]: AdminRole::Admin
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub enum AdminRole {
36    /// Reads every page and API route; may still act on their **own** account
37    /// (password, sessions, second factor, logout). Refused every shared or CA
38    /// mutation.
39    Viewer,
40    /// [`Viewer`], plus every CA mutation: revoke a certificate, deactivate or
41    /// delete an ACME account, delete an order, mint or revoke EAB credentials,
42    /// run a nonce sweep. Refused the colleague-management surface.
43    ///
44    /// [`Viewer`]: AdminRole::Viewer
45    Operator,
46    /// [`Operator`], plus `/operators/*` -- disabling or re-enabling a
47    /// colleague, resetting their second factor, revoking one of their
48    /// sessions. Everything.
49    ///
50    /// [`Operator`]: AdminRole::Operator
51    Admin,
52}
53
54impl AdminRole {
55    /// Every value, low privilege to high -- the order the variants are
56    /// declared in, and the one an error message lists them in.
57    pub const ALL: &'static [Self] = &[Self::Viewer, Self::Operator, Self::Admin];
58
59    /// The exact string stored in `admin_users.role`.
60    #[must_use]
61    pub fn as_str(self) -> &'static str {
62        match self {
63            Self::Viewer => "viewer",
64            Self::Operator => "operator",
65            Self::Admin => "admin",
66        }
67    }
68
69    /// Reads the column back. **Infallible**: `NULL` and `"admin"` are [`Admin`],
70    /// and anything unrecognised is [`Viewer`] -- the least-privilege direction,
71    /// the same fail-closed choice [`AdminUser::is_active`] makes for a `status`
72    /// outside its `CHECK`. A garbage value can only arrive by a hand-edit of
73    /// the database; every write path here goes through a canonical
74    /// [`AdminRole::as_str`].
75    ///
76    /// [`Admin`]: AdminRole::Admin
77    /// [`Viewer`]: AdminRole::Viewer
78    #[must_use]
79    pub fn from_storage(raw: Option<&str>) -> Self {
80        match raw {
81            None | Some("admin") => Self::Admin,
82            Some("operator") => Self::Operator,
83            _ => Self::Viewer,
84        }
85    }
86}
87
88impl std::fmt::Display for AdminRole {
89    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        formatter.write_str(self.as_str())
91    }
92}
93
94/// Whether a web-admin operator may sign in at all.
95///
96/// The two values `admin_users.status`'s `CHECK` allows, as a type rather than
97/// as the free strings four call sites used to pass. The migration is frozen
98/// and its spellings are the compatibility surface, so [`AdminStatus::as_str`]
99/// answers the byte-identical value the column already holds -- the
100/// `crates/store/src/status.rs` treatment, kept here beside [`AdminRole`] because the
101/// two are read together and neither is a state machine the way an order's
102/// status is.
103///
104/// Deliberately no `from_storage`: [`AdminUser::is_active`] is the only reader
105/// and it already fails closed on a value outside the `CHECK`, which is the
106/// direction a hand-edited row should fall.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
108pub enum AdminStatus {
109    /// May sign in.
110    Active,
111    /// May not. Setting this also revokes every session the operator holds --
112    /// a disabled account with a live cookie would be disabled in name only.
113    Disabled,
114}
115
116impl AdminStatus {
117    /// The exact string stored in `admin_users.status`.
118    #[must_use]
119    pub fn as_str(self) -> &'static str {
120        match self {
121            Self::Active => "active",
122            Self::Disabled => "disabled",
123        }
124    }
125}
126
127impl std::fmt::Display for AdminStatus {
128    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        formatter.write_str(self.as_str())
130    }
131}
132
133impl FromStr for AdminRole {
134    type Err = String;
135
136    /// For operator input (`admin user create --role`, `admin user role`). An
137    /// unknown value is refused **by name** listing the alternatives -- the
138    /// `--status` / `--event` rule, since a silent fallback would be a privilege
139    /// decision made by a typo.
140    fn from_str(value: &str) -> Result<Self, Self::Err> {
141        match value {
142            "viewer" => Ok(Self::Viewer),
143            "operator" => Ok(Self::Operator),
144            "admin" => Ok(Self::Admin),
145            other => Err(format!(
146                "unknown role `{other}` (expected one of: viewer, operator, admin)"
147            )),
148        }
149    }
150}
151
152/// An operator of the web admin interface.
153///
154/// Not an ACME concept and never joined to one: an [`AdminUser`] is a person
155/// with a password, an `accounts` row is a client key. There is no `profile`
156/// column -- an admin user sees every endpoint this process serves.
157///
158/// ## Methods
159///
160/// - `create`: persist a new operator, `active`
161/// - `find_by_id` / `find_by_username`: lookup (the latter is the login path)
162/// - `list_all`: every operator, oldest first
163/// - `set_password_hash` / `set_status` / `set_role` / `mark_logged_in`:
164///   in-place updates
165/// - `role`: the privilege tier, `NULL` resolved to [`AdminRole::Admin`]
166/// - `set_totp_pending` / `confirm_totp` / `clear_totp` / `claim_totp_step`:
167///   the second factor's lifecycle, and RFC 6238 §5.2's replay guard
168/// - `delete`: remove, cascading to the operator's sessions and recovery codes
169/// - `to_json`: admin-facing rendering (never the password hash, never a secret)
170#[derive(Debug, Clone)]
171pub struct AdminUser {
172    pub id: Uuid,
173    /// Always lowercase: [`AdminUser::create`] normalizes before writing, so
174    /// `Alice` and `alice` cannot become two logins that read as one.
175    pub username: String,
176    /// The encoded KDF output -- see `admin::password`. Never rendered.
177    pub password_hash: String,
178    pub status: String,
179    /// The privilege tier, raw from the column. `None` is a row that predates
180    /// the `role` column and reads as [`AdminRole::Admin`]; use
181    /// [`AdminUser::role`] rather than matching this directly.
182    pub role: Option<String>,
183    /// Set once the owner has proven a code against a pending enrolment.
184    /// `None` means no second factor is configured.
185    pub totp_secret: Option<Vec<u8>>,
186    /// An enrolment begun but not yet confirmed. Not a usable second factor.
187    pub totp_pending_secret: Option<Vec<u8>>,
188    /// The last TOTP time step accepted, so a code cannot be replayed inside
189    /// its own window.
190    pub totp_last_step: Option<i64>,
191    pub created_at: i64,
192    pub updated_at: i64,
193    pub last_login_at: Option<i64>,
194    /// Where to send this operator security notifications (a completed sign-in
195    /// from an unfamiliar address, a refused second factor, a credential
196    /// change). `None` means none are sent -- the event is still logged.
197    pub contact_email: Option<String>,
198    /// The operator's recent distinct login addresses, most-recent-first,
199    /// capped at [`KNOWN_LOGIN_IPS`]. Compared against the live request, but
200    /// **only** to decide whether to notify -- never to authorise. Persisted as
201    /// a JSON array (the `accounts.contact` convention).
202    pub known_login_ips: Vec<String>,
203}
204
205/// Every column of `admin_users`, in one place: each read must select the same set
206/// or `from_row` fails on whichever forgot one.
207///
208/// A `macro_rules!` rather than a `const` so the expansion is a string
209/// *literal*, which is what `sqlx::query`'s `SqlSafeStr` bound requires.
210macro_rules! columns {
211    () => {
212        "id, username, password_hash, status, role, totp_secret, totp_pending_secret, \
213         totp_last_step, created_at, updated_at, last_login_at, contact_email, known_login_ips"
214    };
215}
216
217/// How many recent distinct login addresses [`AdminUser::known_login_ips`]
218/// keeps. A completed sign-in from an address outside this set (and only while
219/// the set is non-empty) is what raises the "unusual location" operator
220/// notification -- so the bound is a trade between an operator who moves
221/// between a few networks not being alerted on every switch, and a stale entry
222/// not masking a genuinely new address for too long. Not a config key: the
223/// right value does not depend on the deployment.
224pub const KNOWN_LOGIN_IPS: usize = 5;
225
226impl AdminUser {
227    fn from_row(row: Row) -> Result<Self, sqlx::Error> {
228        Ok(AdminUser {
229            id: row.try_get("id")?,
230            username: row.try_get("username")?,
231            password_hash: row.try_get("password_hash")?,
232            status: row.try_get("status")?,
233            role: row.try_get("role")?,
234            totp_secret: row.try_get("totp_secret")?,
235            totp_pending_secret: row.try_get("totp_pending_secret")?,
236            totp_last_step: row.try_get("totp_last_step")?,
237            created_at: row.try_get("created_at")?,
238            updated_at: row.try_get("updated_at")?,
239            last_login_at: row.try_get("last_login_at")?,
240            contact_email: row.try_get("contact_email")?,
241            known_login_ips: {
242                let raw: String = row.try_get("known_login_ips")?;
243                serde_json::from_str(&raw).map_err(|e| sqlx::Error::Decode(Box::new(e)))?
244            },
245        })
246    }
247
248    /// Persists a new operator, `active`. `username` is lowercased and trimmed
249    /// here rather than at the call sites, so every path -- the CLI, a future
250    /// API -- stores the same thing.
251    ///
252    /// `password_hash` is already encoded by `admin::password`: this
253    /// layer never sees a plaintext password and cannot hash one.
254    ///
255    /// `role` is written **in the same INSERT**. `None` leaves the column
256    /// `NULL`, which reads as [`AdminRole::Admin`] -- the safe default for the
257    /// bootstrap operator, and what every row created before the column existed
258    /// holds. It used to be the only option, with `admin::users::create_user`
259    /// calling [`AdminUser::set_role`] afterwards for a narrower tier; that made
260    /// `admin user create --role viewer` two writes, so a failure between them
261    /// left an operator at full `admin` with their password already set.
262    ///
263    /// A duplicate username surfaces as the UNIQUE violation it is; the caller
264    /// (`admin::users::create_user`) checks first and reports it in words.
265    pub async fn create(
266        username: &str,
267        password_hash: &str,
268        role: Option<AdminRole>,
269        database: &Database,
270    ) -> Result<AdminUser, sqlx::Error> {
271        let now = now_secs();
272        let user = AdminUser {
273            id: crate::id::mint(),
274            username: username.trim().to_lowercase(),
275            password_hash: password_hash.to_string(),
276            status: "active".to_string(),
277            role: role.map(|role| role.as_str().to_string()),
278            totp_secret: None,
279            totp_pending_secret: None,
280            totp_last_step: None,
281            created_at: now,
282            updated_at: now,
283            last_login_at: None,
284            contact_email: None,
285            known_login_ips: Vec::new(),
286        };
287
288        debug!(event = "db_admin_user_create_started", outcome = "progress", username = %user.username);
289        crate::sql::query(
290            "INSERT INTO admin_users \
291             (id, username, password_hash, status, role, created_at, updated_at) \
292             VALUES (?, ?, ?, ?, ?, ?, ?);",
293        )
294        .bind(user.id)
295        .bind(&user.username)
296        .bind(&user.password_hash)
297        .bind(&user.status)
298        .bind(user.role.clone())
299        .bind(user.created_at)
300        .bind(user.updated_at)
301        .execute(database)
302        .await?;
303
304        info!(event = "db_admin_user_created", outcome = "success", user_id = %user.id, username = %user.username);
305        Ok(user)
306    }
307
308    /// Looks an operator up by id: the session path, which carries the id.
309    pub async fn find_by_id(
310        id: Uuid,
311        database: &Database,
312    ) -> Result<Option<AdminUser>, sqlx::Error> {
313        debug!(event = "db_admin_user_find_by_id_started", outcome = "progress", id = ?id);
314        let row = crate::sql::query(concat!(
315            "SELECT ",
316            columns!(),
317            " FROM admin_users WHERE id = ?;"
318        ))
319        .bind(id)
320        .fetch_optional(database)
321        .await?;
322
323        row.map(AdminUser::from_row).transpose()
324    }
325
326    /// The login path. Lowercases the argument for the same reason
327    /// [`AdminUser::create`] does -- a login typed `Alice` must find `alice`.
328    pub async fn find_by_username(
329        username: &str,
330        database: &Database,
331    ) -> Result<Option<AdminUser>, sqlx::Error> {
332        debug!(
333            event = "db_admin_user_find_by_username_started",
334            outcome = "progress"
335        );
336        let row = crate::sql::query(concat!(
337            "SELECT ",
338            columns!(),
339            " FROM admin_users WHERE username = ?;"
340        ))
341        .bind(username.trim().to_lowercase())
342        .fetch_optional(database)
343        .await?;
344
345        row.map(AdminUser::from_row).transpose()
346    }
347
348    /// Every operator, oldest first.
349    ///
350    /// A **scan**, not a listing: its one caller is
351    /// `admin::mfa::operators_without_a_factor`, which counts the operators
352    /// with no confirmed factor for the `admin.require_mfa` startup warning.
353    /// Nothing renders it, which is why it can sit beside [`AdminUser::search`]
354    /// without being the second listing the paging pass deleted
355    /// `Account::list_all` for -- an order nothing displays cannot disagree
356    /// with the paged one.
357    pub async fn list_all(database: &Database) -> Result<Vec<AdminUser>, sqlx::Error> {
358        debug!(
359            event = "db_admin_user_list_all_started",
360            outcome = "progress"
361        );
362        let rows = crate::sql::query(concat!(
363            "SELECT ",
364            columns!(),
365            " FROM admin_users ORDER BY created_at ASC, id ASC;"
366        ))
367        .fetch_all(database)
368        .await?;
369
370        rows.into_iter().map(AdminUser::from_row).collect()
371    }
372
373    /// One page of `admin user list`, plus the total the table holds unpaged.
374    ///
375    /// **Oldest first**, and the one listing in the binary that is: every other
376    /// paged listing puts the newest row on top, but the bootstrap operator --
377    /// the one created before the panel could be signed in to at all -- is
378    /// precisely the row whose position should not move as colleagues are
379    /// added. `id` breaks the `created_at` tie for `Eab::search`'s reason:
380    /// `created_at` is a whole second, and operators are created in one go.
381    pub async fn search(
382        limit: i64,
383        offset: i64,
384        database: &Database,
385    ) -> Result<(Vec<AdminUser>, i64), sqlx::Error> {
386        debug!(
387            event = "db_admin_user_search_started",
388            outcome = "progress",
389            limit = limit,
390            offset = offset
391        );
392        let rows = crate::sql::query(concat!(
393            "SELECT ",
394            columns!(),
395            " FROM admin_users ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?;"
396        ))
397        .bind(limit)
398        .bind(offset)
399        .fetch_all(database)
400        .await?;
401        let total: i64 = crate::sql::query("SELECT COUNT(*) FROM admin_users;")
402            .fetch_one(database)
403            .await?
404            .try_get(0)?;
405
406        let users = rows
407            .into_iter()
408            .map(AdminUser::from_row)
409            .collect::<Result<_, _>>()?;
410        Ok((users, total))
411    }
412
413    /// Replaces the stored hash. Callers are responsible for invalidating the
414    /// owner's sessions -- `admin::users::set_password` does, and a password
415    /// change that left them alive would be a change in name only.
416    pub async fn set_password_hash(
417        &mut self,
418        password_hash: &str,
419        database: &Database,
420    ) -> Result<(), sqlx::Error> {
421        let now = now_secs();
422        crate::sql::query("UPDATE admin_users SET password_hash = ?, updated_at = ? WHERE id = ?;")
423            .bind(password_hash)
424            .bind(now)
425            .bind(self.id)
426            .execute(database)
427            .await?;
428
429        self.password_hash = password_hash.to_string();
430        self.updated_at = now;
431        info!(event = "db_admin_user_password_changed", outcome = "success", user_id = %self.id, username = %self.username);
432        Ok(())
433    }
434
435    /// Moves between `active` and `disabled`. A disabled operator cannot log
436    /// in, and an existing session of theirs is refused on its next use --
437    /// the session rows are left for the reaper rather than deleted here, so
438    /// re-enabling is a single UPDATE either way.
439    pub async fn set_status(
440        &mut self,
441        status: &str,
442        database: &Database,
443    ) -> Result<(), sqlx::Error> {
444        let now = now_secs();
445        crate::sql::query("UPDATE admin_users SET status = ?, updated_at = ? WHERE id = ?;")
446            .bind(status)
447            .bind(now)
448            .bind(self.id)
449            .execute(database)
450            .await?;
451
452        self.status = status.to_string();
453        self.updated_at = now;
454        info!(event = "db_admin_user_status_changed", outcome = "success", user_id = %self.id, username = %self.username, status = %status);
455        Ok(())
456    }
457
458    /// Sets the privilege tier. Callers revoke the operator's sessions --
459    /// `admin::users::set_role` does, matching a `disable` and a password
460    /// change; the write extractors also re-read `role` every request, so a
461    /// demotion takes effect on the next call regardless.
462    pub async fn set_role(
463        &mut self,
464        role: AdminRole,
465        database: &Database,
466    ) -> Result<(), sqlx::Error> {
467        let now = now_secs();
468        crate::sql::query("UPDATE admin_users SET role = ?, updated_at = ? WHERE id = ?;")
469            .bind(role.as_str())
470            .bind(now)
471            .bind(self.id)
472            .execute(database)
473            .await?;
474
475        self.role = Some(role.as_str().to_string());
476        self.updated_at = now;
477        info!(event = "db_admin_user_role_changed", outcome = "success", user_id = %self.id, username = %self.username, role = %role);
478        Ok(())
479    }
480
481    /// Sets (or clears, with `None`) the address this operator receives security
482    /// notifications at. Not a credential -- no session is revoked. The address
483    /// shape is the caller's to validate (`admin::users::set_contact_email`);
484    /// this layer only stores what it is handed.
485    pub async fn set_contact_email(
486        &mut self,
487        email: Option<&str>,
488        database: &Database,
489    ) -> Result<(), sqlx::Error> {
490        let now = now_secs();
491        crate::sql::query("UPDATE admin_users SET contact_email = ?, updated_at = ? WHERE id = ?;")
492            .bind(email)
493            .bind(now)
494            .bind(self.id)
495            .execute(database)
496            .await?;
497
498        self.contact_email = email.map(str::to_string);
499        self.updated_at = now;
500        info!(event = "db_admin_user_contact_changed", outcome = "success", user_id = %self.id, username = %self.username, cleared = email.is_none());
501        Ok(())
502    }
503
504    /// Stores an enrolment the owner has not yet proven a code against.
505    ///
506    /// Not a usable second factor: [`AdminUser::has_totp`] stays `false` until
507    /// [`AdminUser::confirm_totp`] moves it across, which is what stops an
508    /// abandoned enrolment from locking its own owner out.
509    pub async fn set_totp_pending(
510        &mut self,
511        secret: &[u8],
512        database: &Database,
513    ) -> Result<(), sqlx::Error> {
514        let now = now_secs();
515        crate::sql::query(
516            "UPDATE admin_users SET totp_pending_secret = ?, updated_at = ? WHERE id = ?;",
517        )
518        .bind(secret)
519        .bind(now)
520        .bind(self.id)
521        .execute(database)
522        .await?;
523
524        self.totp_pending_secret = Some(secret.to_vec());
525        self.updated_at = now;
526        info!(event = "db_admin_totp_enrolment_started", outcome = "progress", user_id = %self.id, username = %self.username);
527        Ok(())
528    }
529
530    /// Promotes the pending secret to the real one.
531    ///
532    /// One statement, deliberately: a half-applied enrolment would leave the
533    /// operator believing they have a factor that nothing checks, or holding a
534    /// pending secret alongside a live one. `totp_last_step` is cleared with
535    /// them -- the replay guard belongs to the secret it was recorded against.
536    ///
537    /// A no-op when nothing is pending, so a double-submit cannot clear a live
538    /// factor.
539    pub async fn confirm_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
540        let Some(pending) = self.totp_pending_secret.clone() else {
541            return Ok(());
542        };
543
544        let now = now_secs();
545        crate::sql::query(
546            "UPDATE admin_users SET totp_secret = totp_pending_secret, \
547             totp_pending_secret = NULL, totp_last_step = NULL, updated_at = ? \
548             WHERE id = ? AND totp_pending_secret IS NOT NULL;",
549        )
550        .bind(now)
551        .bind(self.id)
552        .execute(database)
553        .await?;
554
555        self.totp_secret = Some(pending);
556        self.totp_pending_secret = None;
557        self.totp_last_step = None;
558        self.updated_at = now;
559        info!(event = "db_admin_totp_enabled", outcome = "success", user_id = %self.id, username = %self.username);
560        Ok(())
561    }
562
563    /// Removes the factor, any half-finished enrolment and the replay guard
564    /// together. Callers drop the recovery codes too -- a code that recovers
565    /// access to a factor that no longer exists is a second password.
566    pub async fn clear_totp(&mut self, database: &Database) -> Result<(), sqlx::Error> {
567        let now = now_secs();
568        crate::sql::query(
569            "UPDATE admin_users SET totp_secret = NULL, totp_pending_secret = NULL, \
570             totp_last_step = NULL, updated_at = ? WHERE id = ?;",
571        )
572        .bind(now)
573        .bind(self.id)
574        .execute(database)
575        .await?;
576
577        self.totp_secret = None;
578        self.totp_pending_secret = None;
579        self.totp_last_step = None;
580        self.updated_at = now;
581        info!(event = "db_admin_totp_disabled", outcome = "success", user_id = %self.id, username = %self.username);
582        Ok(())
583    }
584
585    /// Records `step` as accepted, refusing one that is not strictly newer than
586    /// the stored value -- RFC 6238 §5.2's replay guard.
587    ///
588    /// The comparison lives in the `WHERE` clause rather than in Rust: a code
589    /// observed in flight and resubmitted inside its own 30-second window must
590    /// not be accepted twice, and with two requests racing it is
591    /// `rows_affected` that decides which one was first. Same primitive as
592    /// `Nonce::verify`.
593    pub async fn claim_totp_step(
594        &mut self,
595        step: i64,
596        database: &Database,
597    ) -> Result<bool, sqlx::Error> {
598        let now = now_secs();
599        let result = crate::sql::query(
600            "UPDATE admin_users SET totp_last_step = ?, updated_at = ? \
601             WHERE id = ? AND (totp_last_step IS NULL OR totp_last_step < ?);",
602        )
603        .bind(step)
604        .bind(now)
605        .bind(self.id)
606        .bind(step)
607        .execute(database)
608        .await?;
609
610        let claimed = result.rows_affected() == 1;
611        if claimed {
612            self.totp_last_step = Some(step);
613            self.updated_at = now;
614        }
615        Ok(claimed)
616    }
617
618    /// Stamps `last_login_at`, and folds `client_ip` into `known_login_ips`
619    /// (move-to-front, deduplicated, capped at [`KNOWN_LOGIN_IPS`]). Advisory
620    /// only -- nothing authorises on either column; the address set exists so a
621    /// sign-in from an unfamiliar address can be noticed.
622    ///
623    /// Called when a login *completes*, which for an operator with a second
624    /// factor is one request later than the password being accepted. A caller
625    /// that needs the *pre-login* address set (to decide whether this sign-in
626    /// is from a new address) must read `known_login_ips` before calling.
627    pub async fn mark_logged_in(
628        &mut self,
629        client_ip: Option<&str>,
630        database: &Database,
631    ) -> Result<(), sqlx::Error> {
632        let now = now_secs();
633
634        if let Some(ip) = client_ip.filter(|ip| !ip.is_empty()) {
635            let mut known = Vec::with_capacity(KNOWN_LOGIN_IPS);
636            known.push(ip.to_string());
637            known.extend(
638                self.known_login_ips
639                    .iter()
640                    .filter(|seen| seen.as_str() != ip)
641                    .take(KNOWN_LOGIN_IPS - 1)
642                    .cloned(),
643            );
644            // `Vec<String>` serialization is infallible.
645            let known_json = Value::from(known.clone()).to_string();
646            crate::sql::query(
647                "UPDATE admin_users SET last_login_at = ?, known_login_ips = ? WHERE id = ?;",
648            )
649            .bind(now)
650            .bind(known_json)
651            .bind(self.id)
652            .execute(database)
653            .await?;
654            self.known_login_ips = known;
655        } else {
656            crate::sql::query("UPDATE admin_users SET last_login_at = ? WHERE id = ?;")
657                .bind(now)
658                .bind(self.id)
659                .execute(database)
660                .await?;
661        }
662
663        self.last_login_at = Some(now);
664        Ok(())
665    }
666
667    /// Removes the operator. Their sessions go with them via the schema's
668    /// `ON DELETE CASCADE`, which needs `foreign_keys` on -- `Database::open`
669    /// and `connect_in_memory` both pin it. Returns whether a row existed.
670    pub async fn delete(id: Uuid, database: &Database) -> Result<bool, sqlx::Error> {
671        debug!(event = "db_admin_user_delete_started", outcome = "progress", id = ?id);
672        let result = crate::sql::query("DELETE FROM admin_users WHERE id = ?;")
673            .bind(id)
674            .execute(database)
675            .await?;
676
677        let deleted = result.rows_affected() > 0;
678        if deleted {
679            info!(event = "db_admin_user_deleted", outcome = "success", user_id = %id);
680        }
681        Ok(deleted)
682    }
683
684    /// Whether this operator may log in and hold a session.
685    #[must_use]
686    pub fn is_active(&self) -> bool {
687        self.status == "active"
688    }
689
690    /// The privilege tier, with `NULL` resolved to [`AdminRole::Admin`]. Match
691    /// on this, never on the raw [`AdminUser::role`] field.
692    #[must_use]
693    pub fn role(&self) -> AdminRole {
694        AdminRole::from_storage(self.role.as_deref())
695    }
696
697    /// Whether a confirmed second factor is configured. A pending enrolment
698    /// does not count -- it has never been proven against a code.
699    #[must_use]
700    pub fn has_totp(&self) -> bool {
701        self.totp_secret.is_some()
702    }
703
704    /// Whether an enrolment is half-finished: a secret was generated and shown,
705    /// and no code has proven it yet.
706    ///
707    /// Deliberately not folded into [`AdminUser::has_totp`] and deliberately
708    /// not in [`AdminUser::to_json`]: the login path must treat this operator as
709    /// having *no* factor, and the only surface that cares is the enrolment
710    /// page deciding whether to offer "start over".
711    #[must_use]
712    pub fn has_pending_totp(&self) -> bool {
713        self.totp_pending_secret.is_some()
714    }
715
716    /// The admin-facing rendering. **Never** includes `password_hash`, nor
717    /// either TOTP secret -- only whether one is configured.
718    #[must_use]
719    pub fn to_json(&self) -> Value {
720        serde_json::json!({
721            "id": self.id,
722            "username": self.username,
723            "status": self.status,
724            "role": self.role().as_str(),
725            "totpEnabled": self.has_totp(),
726            "createdAt": rfc3339(self.created_at),
727            "updatedAt": rfc3339(self.updated_at),
728            "lastLoginAt": self.last_login_at.map(rfc3339),
729            "contactEmail": self.contact_email,
730            "knownLoginIps": self.known_login_ips,
731        })
732    }
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use std::sync::Arc;
739
740    async fn db() -> Arc<Database> {
741        Arc::new(Database::connect_for_test().await.unwrap())
742    }
743
744    #[tokio::test]
745    async fn create_persists_an_active_user_with_a_lowercased_username() {
746        let db = db().await;
747        let user = AdminUser::create("  Alice  ", "hash", None, &db)
748            .await
749            .unwrap();
750        assert_eq!(user.username, "alice");
751        assert_eq!(user.status, "active");
752        assert!(user.is_active());
753        assert!(user.last_login_at.is_none());
754        assert!(!user.has_totp());
755    }
756
757    #[tokio::test]
758    async fn find_by_username_is_case_insensitive_and_round_trips() {
759        let db = db().await;
760        let created = AdminUser::create("alice", "hash", None, &db).await.unwrap();
761        let found = AdminUser::find_by_username("ALICE", &db)
762            .await
763            .unwrap()
764            .unwrap();
765        assert_eq!(found.id, created.id);
766        assert_eq!(found.password_hash, "hash");
767
768        let by_id = AdminUser::find_by_id(created.id, &db)
769            .await
770            .unwrap()
771            .unwrap();
772        assert_eq!(by_id.username, "alice");
773    }
774
775    #[tokio::test]
776    async fn lookups_of_unknown_users_return_none() {
777        let db = db().await;
778        assert!(
779            AdminUser::find_by_username("nobody", &db)
780                .await
781                .unwrap()
782                .is_none()
783        );
784        assert!(
785            AdminUser::find_by_id(crate::id::mint(), &db)
786                .await
787                .unwrap()
788                .is_none()
789        );
790    }
791
792    #[tokio::test]
793    async fn a_duplicate_username_is_refused_by_the_unique_constraint() {
794        let db = db().await;
795        AdminUser::create("alice", "hash", None, &db).await.unwrap();
796        // Also proves the normalization above is a real constraint, not a
797        // near-miss: `Alice` collides with the stored `alice`.
798        let error = AdminUser::create("Alice", "other", None, &db)
799            .await
800            .unwrap_err();
801        assert!(
802            crate::sql::is_unique_violation(&error),
803            "expected a UNIQUE violation, got: {error}"
804        );
805    }
806
807    #[tokio::test]
808    async fn list_all_returns_every_user_and_empty_is_empty() {
809        let db = db().await;
810        assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
811
812        AdminUser::create("a", "h", None, &db).await.unwrap();
813        AdminUser::create("b", "h", None, &db).await.unwrap();
814        let all = AdminUser::list_all(&db).await.unwrap();
815        assert_eq!(all.len(), 2);
816        // Two users created in the same second tie on `created_at`, so the
817        // `id ASC` tiebreak decides -- and since `crate::id::mint` is a UUID
818        // v7, whose leading 48 bits are a millisecond timestamp, that tiebreak
819        // is insertion order. It used to be a random UUID, so this assertion
820        // failed one run in two and the test sorted the names before making it.
821        //
822        // Only for rows minted since that change: nothing was backfilled, so a
823        // v4 still ties arbitrarily against a v7 in the same second, for ever.
824        let names: Vec<&str> = all.iter().map(|u| u.username.as_str()).collect();
825        assert_eq!(names, ["a", "b"], "the v7 tiebreak is insertion order");
826    }
827
828    /// The window `admin user list` hands down. Both facts a page needs: the
829    /// rows it holds, and the total it is a page *of*.
830    #[tokio::test]
831    async fn search_pages_without_overlap_and_reports_the_unpaged_total() {
832        let db = db().await;
833        assert_eq!(AdminUser::search(50, 0, &db).await.unwrap().1, 0);
834
835        for name in ["a", "b", "c", "d", "e"] {
836            AdminUser::create(name, "h", None, &db).await.unwrap();
837        }
838
839        let (first, total) = AdminUser::search(2, 0, &db).await.unwrap();
840        let (second, also_total) = AdminUser::search(2, 2, &db).await.unwrap();
841        let (third, _) = AdminUser::search(2, 4, &db).await.unwrap();
842
843        assert_eq!((total, also_total), (5, 5), "the total is the table");
844        assert_eq!((first.len(), second.len(), third.len()), (2, 2, 1));
845
846        // Walked end to end the pages are the table exactly once, which is both
847        // "no overlap" and "nothing skipped" in one assertion -- and in the
848        // creation order the `id` tiebreak preserves, since all five tie on
849        // `created_at`.
850        let walked: Vec<&str> = first
851            .iter()
852            .chain(second.iter())
853            .chain(third.iter())
854            .map(|user| user.username.as_str())
855            .collect();
856        assert_eq!(walked, ["a", "b", "c", "d", "e"]);
857    }
858
859    /// Oldest first, and the only listing in the binary that is: the bootstrap
860    /// operator is the row whose position should not move as colleagues are
861    /// added.
862    #[tokio::test]
863    async fn search_reads_the_table_in_the_same_order_as_the_scan() {
864        let db = db().await;
865        for name in ["a", "b", "c"] {
866            AdminUser::create(name, "h", None, &db).await.unwrap();
867        }
868
869        let scanned: Vec<String> = AdminUser::list_all(&db)
870            .await
871            .unwrap()
872            .into_iter()
873            .map(|user| user.username)
874            .collect();
875        let paged: Vec<String> = AdminUser::search(50, 0, &db)
876            .await
877            .unwrap()
878            .0
879            .into_iter()
880            .map(|user| user.username)
881            .collect();
882        assert_eq!(paged, scanned);
883    }
884
885    #[tokio::test]
886    async fn list_all_orders_oldest_first() {
887        let db = db().await;
888        let older = AdminUser::create("older", "h", None, &db).await.unwrap();
889        let newer = AdminUser::create("newer", "h", None, &db).await.unwrap();
890        // Backdate one so the two no longer tie and `created_at ASC` is what
891        // decides, rather than the UUID tiebreak.
892        crate::sql::query("UPDATE admin_users SET created_at = ? WHERE id = ?;")
893            .bind(older.created_at - 60)
894            .bind(older.id)
895            .execute(&db)
896            .await
897            .unwrap();
898
899        let all = AdminUser::list_all(&db).await.unwrap();
900        assert_eq!(all[0].id, older.id);
901        assert_eq!(all[1].id, newer.id);
902    }
903
904    #[tokio::test]
905    async fn set_password_hash_persists_and_syncs_in_memory() {
906        let db = db().await;
907        let mut user = AdminUser::create("alice", "old", None, &db).await.unwrap();
908        user.set_password_hash("new", &db).await.unwrap();
909        assert_eq!(user.password_hash, "new");
910
911        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
912        assert_eq!(reloaded.password_hash, "new");
913    }
914
915    #[tokio::test]
916    async fn set_status_persists_and_disables() {
917        let db = db().await;
918        let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
919        user.set_status("disabled", &db).await.unwrap();
920        assert!(!user.is_active());
921
922        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
923        assert!(!reloaded.is_active());
924    }
925
926    #[test]
927    fn admin_role_maps_storage_both_ways() {
928        // NULL and "admin" are the full tier; every unknown value is the
929        // least-privilege one, the fail-closed direction.
930        assert_eq!(AdminRole::from_storage(None), AdminRole::Admin);
931        assert_eq!(AdminRole::from_storage(Some("admin")), AdminRole::Admin);
932        assert_eq!(
933            AdminRole::from_storage(Some("operator")),
934            AdminRole::Operator
935        );
936        assert_eq!(AdminRole::from_storage(Some("viewer")), AdminRole::Viewer);
937        assert_eq!(AdminRole::from_storage(Some("root")), AdminRole::Viewer);
938        assert_eq!(AdminRole::from_storage(Some("")), AdminRole::Viewer);
939
940        for role in AdminRole::ALL {
941            assert_eq!(
942                AdminRole::from_storage(Some(role.as_str())),
943                *role,
944                "as_str and from_storage must round-trip"
945            );
946        }
947
948        // Declared low to high, so `>=` is a usable gate.
949        assert!(AdminRole::Viewer < AdminRole::Operator);
950        assert!(AdminRole::Operator < AdminRole::Admin);
951    }
952
953    #[test]
954    fn admin_role_from_str_refuses_an_unknown_value_by_name() {
955        assert_eq!("operator".parse::<AdminRole>(), Ok(AdminRole::Operator));
956        let error = "supervisor".parse::<AdminRole>().unwrap_err();
957        assert!(error.contains("supervisor"), "{error}");
958        assert!(error.contains("viewer, operator, admin"), "{error}");
959    }
960
961    #[tokio::test]
962    async fn a_row_with_no_role_reads_as_admin() {
963        let db = db().await;
964        let user = AdminUser::create("alice", "h", None, &db).await.unwrap();
965        assert_eq!(user.role, None);
966        assert_eq!(user.role(), AdminRole::Admin);
967
968        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
969        assert_eq!(reloaded.role, None);
970        assert_eq!(reloaded.role(), AdminRole::Admin);
971    }
972
973    #[tokio::test]
974    async fn set_role_persists_and_syncs_in_memory() {
975        let db = db().await;
976        let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
977
978        user.set_role(AdminRole::Viewer, &db).await.unwrap();
979        assert_eq!(user.role(), AdminRole::Viewer);
980
981        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
982        assert_eq!(reloaded.role.as_deref(), Some("viewer"));
983        assert_eq!(reloaded.role(), AdminRole::Viewer);
984
985        user.set_role(AdminRole::Operator, &db).await.unwrap();
986        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
987        assert_eq!(reloaded.role(), AdminRole::Operator);
988    }
989
990    #[tokio::test]
991    async fn the_totp_setters_persist_and_sync_in_memory() {
992        let db = db().await;
993        let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
994
995        user.set_totp_pending(b"secret-bytes", &db).await.unwrap();
996        assert!(user.has_pending_totp());
997        assert!(
998            !user.has_totp(),
999            "a pending enrolment must not read as a second factor"
1000        );
1001        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1002        assert_eq!(
1003            reloaded.totp_pending_secret.as_deref(),
1004            Some(&b"secret-bytes"[..])
1005        );
1006        assert!(!reloaded.has_totp());
1007
1008        user.confirm_totp(&db).await.unwrap();
1009        assert!(user.has_totp());
1010        assert!(!user.has_pending_totp());
1011        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1012        assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"secret-bytes"[..]));
1013        assert_eq!(reloaded.totp_pending_secret, None);
1014
1015        user.clear_totp(&db).await.unwrap();
1016        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1017        assert_eq!(reloaded.totp_secret, None);
1018        assert_eq!(reloaded.totp_pending_secret, None);
1019        assert_eq!(reloaded.totp_last_step, None);
1020    }
1021
1022    /// A double-submit of the confirm form must not clear a live factor by
1023    /// promoting a pending column that is already empty.
1024    #[tokio::test]
1025    async fn confirming_with_nothing_pending_leaves_a_live_factor_alone() {
1026        let db = db().await;
1027        let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1028        user.set_totp_pending(b"live", &db).await.unwrap();
1029        user.confirm_totp(&db).await.unwrap();
1030
1031        user.confirm_totp(&db).await.unwrap();
1032
1033        assert!(user.has_totp());
1034        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1035        assert_eq!(reloaded.totp_secret.as_deref(), Some(&b"live"[..]));
1036    }
1037
1038    /// RFC 6238 §5.2's replay guard, and the reason the comparison is in SQL:
1039    /// two requests carrying one code must not both be accepted.
1040    #[tokio::test]
1041    async fn claim_totp_step_refuses_a_step_it_has_already_seen() {
1042        let db = db().await;
1043        let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1044
1045        assert!(user.claim_totp_step(100, &db).await.unwrap());
1046        assert_eq!(user.totp_last_step, Some(100));
1047
1048        // The same step, and any earlier one, are spent.
1049        assert!(!user.claim_totp_step(100, &db).await.unwrap());
1050        assert!(!user.claim_totp_step(99, &db).await.unwrap());
1051        assert_eq!(
1052            user.totp_last_step,
1053            Some(100),
1054            "a refused claim must not move the guard"
1055        );
1056
1057        // Strictly newer advances it, and persists.
1058        assert!(user.claim_totp_step(101, &db).await.unwrap());
1059        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1060        assert_eq!(reloaded.totp_last_step, Some(101));
1061    }
1062
1063    #[tokio::test]
1064    async fn the_status_check_refuses_a_value_outside_the_schema() {
1065        let db = db().await;
1066        let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1067        assert!(user.set_status("banished", &db).await.is_err());
1068    }
1069
1070    #[tokio::test]
1071    async fn mark_logged_in_stamps_last_login_at() {
1072        let db = db().await;
1073        let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1074        user.mark_logged_in(None, &db).await.unwrap();
1075        assert!(user.last_login_at.is_some());
1076
1077        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1078        assert_eq!(reloaded.last_login_at, user.last_login_at);
1079        assert!(
1080            reloaded.known_login_ips.is_empty(),
1081            "no address was supplied"
1082        );
1083    }
1084
1085    #[tokio::test]
1086    async fn mark_logged_in_keeps_a_capped_move_to_front_address_set() {
1087        let db = db().await;
1088        let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1089
1090        // Fill past the cap; the newest address is always at the front.
1091        for n in 0..KNOWN_LOGIN_IPS + 2 {
1092            user.mark_logged_in(Some(&format!("10.0.0.{n}")), &db)
1093                .await
1094                .unwrap();
1095        }
1096        assert_eq!(user.known_login_ips.len(), KNOWN_LOGIN_IPS);
1097        assert_eq!(
1098            user.known_login_ips[0],
1099            format!("10.0.0.{}", KNOWN_LOGIN_IPS + 1)
1100        );
1101
1102        // A known address moves back to the front rather than being appended.
1103        let known = user.known_login_ips[3].clone();
1104        user.mark_logged_in(Some(&known), &db).await.unwrap();
1105        assert_eq!(user.known_login_ips.len(), KNOWN_LOGIN_IPS);
1106        assert_eq!(user.known_login_ips[0], known);
1107        assert_eq!(
1108            user.known_login_ips
1109                .iter()
1110                .filter(|ip| **ip == known)
1111                .count(),
1112            1,
1113            "deduplicated"
1114        );
1115
1116        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1117        assert_eq!(reloaded.known_login_ips, user.known_login_ips);
1118    }
1119
1120    #[tokio::test]
1121    async fn set_contact_email_persists_and_syncs_and_clears() {
1122        let db = db().await;
1123        let mut user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1124        assert_eq!(user.contact_email, None);
1125
1126        user.set_contact_email(Some("alice@example.com"), &db)
1127            .await
1128            .unwrap();
1129        assert_eq!(user.contact_email.as_deref(), Some("alice@example.com"));
1130        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1131        assert_eq!(reloaded.contact_email.as_deref(), Some("alice@example.com"));
1132
1133        user.set_contact_email(None, &db).await.unwrap();
1134        assert_eq!(user.contact_email, None);
1135        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
1136        assert_eq!(reloaded.contact_email, None);
1137    }
1138
1139    #[tokio::test]
1140    async fn delete_reports_whether_a_row_existed() {
1141        let db = db().await;
1142        let user = AdminUser::create("alice", "h", None, &db).await.unwrap();
1143        assert!(AdminUser::delete(user.id, &db).await.unwrap());
1144        assert!(!AdminUser::delete(user.id, &db).await.unwrap());
1145    }
1146
1147    #[tokio::test]
1148    async fn to_json_never_leaks_the_hash_or_the_totp_secret() {
1149        let db = db().await;
1150        let user = AdminUser::create("alice", "super-secret-hash", None, &db)
1151            .await
1152            .unwrap();
1153        let json = user.to_json();
1154        assert!(json.get("password_hash").is_none());
1155        assert!(json.get("passwordHash").is_none());
1156        assert!(json.get("totpSecret").is_none());
1157        assert!(!json.to_string().contains("super-secret-hash"));
1158        assert_eq!(json["username"], "alice");
1159        assert_eq!(json["status"], "active");
1160        assert_eq!(json["totpEnabled"], false);
1161        assert_eq!(json["lastLoginAt"], Value::Null);
1162        assert_eq!(json["contactEmail"], Value::Null);
1163        assert_eq!(json["knownLoginIps"], serde_json::json!([]));
1164    }
1165}