Skip to main content

acme_proxy_admin/admin/
users.rs

1//! Operator management for the web admin: create, list, re-password, set the
2//! privilege tier, enable, disable, delete, and the password check the login
3//! path runs.
4//!
5//! The operation layer, not a front end: no printing, no HTTP, no terminal.
6//! `src/cli/webadmin.rs` and `crates/admin/src/webadmin/handlers/session.rs` both dispatch
7//! here, which is what keeps the password policy, the duplicate check and the
8//! rehash-on-login identical between them.
9
10use std::io::BufRead;
11use std::sync::Arc;
12
13use tracing::{info, warn};
14
15use crate::admin::password::{self, PasswordContext};
16use crate::admin::prompt::confirm;
17use acme_proxy_store::admin_session::AdminSession;
18use acme_proxy_store::admin_user::AdminRole;
19use acme_proxy_store::admin_user::AdminStatus;
20use acme_proxy_store::admin_user::AdminUser;
21use acme_proxy_store::db::Database;
22
23/// Why creating or re-passwording an operator failed.
24#[derive(Debug, thiserror::Error)]
25pub enum UserError {
26    #[error("database error: {0}")]
27    Database(sqlx::Error),
28    /// The password did not satisfy [`password::check_password_policy`]. The
29    /// string is the operator-facing reason.
30    #[error("{0}")]
31    Policy(String),
32    /// A user by that name already exists. Caught before the INSERT so the
33    /// operator reads a sentence rather than a UNIQUE violation.
34    #[error("an admin user named `{0}` already exists")]
35    DuplicateUsername(String),
36    /// A contact address that does not parse as a mailbox.
37    #[error("{0}")]
38    InvalidContact(String),
39}
40
41impl From<sqlx::Error> for UserError {
42    fn from(error: sqlx::Error) -> Self {
43        Self::Database(error)
44    }
45}
46
47/// The result of checking a username and password.
48///
49/// Every variant but [`AuthOutcome::Authenticated`] must be reported to the
50/// client identically -- one `invalid_credentials`, never "no such user" --
51/// but they are kept apart here so the *log* can say which happened. A caller
52/// that collapses them into the response and not into the log is doing the
53/// right thing with both.
54#[derive(Debug)]
55pub enum AuthOutcome {
56    /// Password verified and the account is usable.
57    Authenticated(Box<AdminUser>),
58    /// No such username. The KDF ran anyway -- see [`authenticate`].
59    UnknownUser,
60    /// The username exists; the password did not match.
61    WrongPassword(Box<AdminUser>),
62    /// The password was right, but the account is `disabled`.
63    Disabled(Box<AdminUser>),
64}
65
66/// Whether a normalized username is one the web admin can address.
67///
68/// The panel routes every colleague operation at `/ui/operators/{username}/…`
69/// and builds those paths by interpolation, so a name holding `/`, `?`, `#` or
70/// a space produces a URL that matches no route — the operator is creatable
71/// from the host and then unmanageable from the panel. minijinja escapes HTML,
72/// not URL syntax, so the templates cannot rescue it either.
73///
74/// The same rule this tree already applies wherever a configured name becomes a
75/// path or an environment segment (`valid_profile_name`,
76/// `valid_config_key_name`), widened by `_` and `.` because an operator name is
77/// a person's, not a slug — `a.smith` and `a_smith` are ordinary and neither
78/// means anything to a URL.
79///
80/// Checked at creation only. An existing row is left alone: refusing to *load*
81/// a username would lock somebody out of a panel they are already using, which
82/// is the opposite of what this is for.
83#[must_use]
84pub fn valid_username(username: &str) -> bool {
85    !username.is_empty()
86        && username
87            .chars()
88            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'))
89}
90
91/// Creates an operator at `role`, or at [`AdminRole::Admin`] when none is
92/// named.
93///
94/// Order matters: the policy is checked before the duplicate lookup, and the
95/// duplicate lookup before the (expensive) hash, so a rejected request never
96/// pays 600 000 iterations.
97///
98/// **One write.** This used to create at `admin` and call [`set_role`]
99/// afterwards, which made `admin user create --role viewer` two statements: a
100/// failure between them left an operator at full authority with their password
101/// already set, and the second statement had to be exempted from `set_role`'s
102/// last-admin guard, since the row it was demoting was the admin it had just
103/// created.
104pub async fn create_user(
105    username: &str,
106    plaintext: &str,
107    context: &PasswordContext,
108    role: Option<AdminRole>,
109    database: Arc<Database>,
110) -> Result<AdminUser, UserError> {
111    password::check_password_policy(plaintext, context).map_err(UserError::Policy)?;
112
113    let normalized = username.trim().to_lowercase();
114    if normalized.is_empty() {
115        return Err(UserError::Policy("username must not be empty".to_string()));
116    }
117    if !valid_username(&normalized) {
118        return Err(UserError::Policy(format!(
119            "invalid username `{normalized}`: use lowercase letters, digits, `-`, `_` and `.` \
120             (the name is a URL segment on the web admin)"
121        )));
122    }
123    if AdminUser::find_by_username(&normalized, &database)
124        .await?
125        .is_some()
126    {
127        return Err(UserError::DuplicateUsername(normalized));
128    }
129
130    let hash = password::hash_password(plaintext);
131    Ok(AdminUser::create(&normalized, &hash, role, &database).await?)
132}
133
134/// Sets an operator's privilege tier and **revokes every session they hold**.
135///
136/// The revocation matches `set_status("disabled")` and `set_password`: a
137/// demotion that left a live `admin` session alive would take effect only when
138/// that cookie happened to expire. It is belt-and-braces rather than
139/// load-bearing -- the write extractors re-read `role` from `admin_users` on
140/// every request -- but this layer already holds that convention. `None` when
141/// there is no such user; otherwise the operator and **how many sessions went
142/// with the change**, which the caller records as its own `session_revoked`
143/// audit row (this layer is front-end agnostic and does not know the actor).
144/// Demoting the **last** `admin` is refused: `/operators/*` is `admin`-only on
145/// both web surfaces, so a deployment with none has no way to manage operators
146/// from the panel at all. Recoverable from this host — which is why it is a
147/// refusal here rather than a `CHECK` — but the operator should hear about it
148/// before it happens rather than after.
149pub async fn set_role(
150    username: &str,
151    role: AdminRole,
152    database: Arc<Database>,
153) -> Result<Option<(AdminUser, u64)>, UserError> {
154    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
155        return Ok(None);
156    };
157
158    if user.role() == AdminRole::Admin && role != AdminRole::Admin {
159        let admins = AdminUser::list_all(&database)
160            .await?
161            .iter()
162            .filter(|other| other.role() == AdminRole::Admin)
163            .count();
164        if admins <= 1 {
165            return Err(UserError::Policy(format!(
166                "`{}` is the only admin: demoting them would leave the panel with no one who \
167                 can manage operators. Promote somebody else first.",
168                user.username
169            )));
170        }
171    }
172
173    user.set_role(role, &database).await?;
174    let revoked = AdminSession::delete_for_user(user.id, &database).await?;
175    Ok(Some((user, revoked)))
176}
177
178/// Sets (`Some`) or clears (`None` / empty / whitespace) the address an
179/// operator receives security notifications at. `Some` is validated as a
180/// mailbox — the same parse [`acme_proxy_jobs::notify::email`] does before it sends —
181/// so a malformed address is refused here rather than becoming a permanent
182/// delivery failure later. Not a credential: sessions are left alone.
183///
184/// `None` when there is no such operator.
185pub async fn set_contact_email(
186    username: &str,
187    contact: Option<&str>,
188    database: Arc<Database>,
189) -> Result<Option<AdminUser>, UserError> {
190    let trimmed = contact.map(str::trim).filter(|value| !value.is_empty());
191    if let Some(address) = trimmed {
192        address
193            .parse::<lettre::message::Mailbox>()
194            .map_err(|error| {
195                UserError::InvalidContact(format!(
196                    "`{address}` is not a valid email address: {error}"
197                ))
198            })?;
199    }
200
201    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
202        return Ok(None);
203    };
204    user.set_contact_email(trimmed, &database).await?;
205    Ok(Some(user))
206}
207
208/// One page of the operators, oldest first, plus the total the table holds.
209///
210/// The direction is [`AdminUser::search`]'s and is the one listing in the
211/// binary that does not put the newest row on top.
212pub async fn list_users(
213    limit: i64,
214    offset: i64,
215    database: Arc<Database>,
216) -> Result<(Vec<AdminUser>, i64), sqlx::Error> {
217    AdminUser::search(limit, offset, &database).await
218}
219
220/// Replaces an operator's password and **revokes every session they hold**.
221///
222/// The revocation is the point: a password changed because it may have leaked,
223/// that left the leaked session alive, would be a change in name only. Returns
224/// `None` when there is no such user; otherwise the operator and how many
225/// sessions the change took with it, for the caller's `session_revoked` row.
226pub async fn set_password(
227    username: &str,
228    plaintext: &str,
229    context: &PasswordContext,
230    database: Arc<Database>,
231) -> Result<Option<(AdminUser, u64)>, UserError> {
232    password::check_password_policy(plaintext, context).map_err(UserError::Policy)?;
233
234    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
235        return Ok(None);
236    };
237
238    let hash = password::hash_password(plaintext);
239    user.set_password_hash(&hash, &database).await?;
240    let revoked = AdminSession::delete_for_user(user.id, &database).await?;
241    Ok(Some((user, revoked)))
242}
243
244/// Changes an operator's own password, keeping the session that requested it
245/// alive and revoking every other one.
246///
247/// The self-service counterpart to [`set_password`]: that one is the CLI's,
248/// trusted because it already runs as the process that can rewrite the row,
249/// and it revokes *every* session including any the operator is currently
250/// using elsewhere. This one is reached over a live session, so the caller's
251/// own session must survive the change or the panel would sign its own
252/// operator out from under them mid-edit -- the `confirm_totp_enrolment`/
253/// `disable_totp` shape, not `set_password`'s.
254///
255/// Does **not** verify the current password: that check (ASVS V6.2.3) happens
256/// one layer up, in the web handler, where the rate limiter and the client
257/// address live.
258pub async fn change_own_password(
259    user: &mut AdminUser,
260    new_password: &str,
261    context: &PasswordContext,
262    keep_session: &str,
263    database: Arc<Database>,
264) -> Result<(), UserError> {
265    password::check_password_policy(new_password, context).map_err(UserError::Policy)?;
266
267    let hash = password::hash_password(new_password);
268    user.set_password_hash(&hash, &database).await?;
269    AdminSession::delete_for_user_except(user.id, keep_session, &database).await?;
270    Ok(())
271}
272
273/// Moves an operator between `active` and `disabled`.
274///
275/// Disabling also drops their sessions: leaving them live would mean a
276/// disabled operator kept working until their cookie happened to expire. That
277/// revocation is audited by the caller as a `session_revoked` row, since only
278/// the front end knows who asked for it.
279///
280/// Takes an [`AdminStatus`] rather than the `&str` five call sites used to
281/// spell by hand. `AdminUser::set_status` below this still takes a string, and
282/// deliberately: it is the raw column write, and the test that a value outside
283/// the migration's `CHECK` is refused by SQLite has to be able to pass one.
284/// This layer is where a typo should stop being expressible — a mistyped
285/// `"enable"` here would have written a status no `is_active` accepts, i.e. a
286/// permanent lockout dressed as a successful re-enable.
287pub async fn set_status(
288    username: &str,
289    status: AdminStatus,
290    database: Arc<Database>,
291) -> Result<Option<(AdminUser, u64)>, sqlx::Error> {
292    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
293        return Ok(None);
294    };
295
296    user.set_status(status.as_str(), &database).await?;
297    let revoked = if status == AdminStatus::Disabled {
298        AdminSession::delete_for_user(user.id, &database).await?
299    } else {
300        0
301    };
302    Ok(Some((user, revoked)))
303}
304
305/// How many operators have no `contact_email` on file.
306///
307/// The `[admin.notify]` security events (`admin_sign_in`,
308/// `admin_credential_changed`) name their own recipient, and for an operator
309/// with no address that recipient is `None` — the message then falls back to
310/// `notify.email.to` or, if that is empty too, goes nowhere at all. Both are
311/// legitimate configurations, and neither is visible from anywhere: the panel
312/// has no contact form and nothing warns. This is what
313/// `announce_admin_listener` counts to say so, the shape
314/// [`crate::admin::mfa::operators_without_a_factor`] already has.
315pub async fn operators_without_a_contact(database: Arc<Database>) -> Result<usize, sqlx::Error> {
316    Ok(AdminUser::list_all(&database)
317        .await?
318        .iter()
319        .filter(|user| user.contact_email.is_none())
320        .count())
321}
322
323/// Revokes every session one operator holds, without touching the account.
324/// `None` when there is no such user.
325pub async fn revoke_sessions(
326    username: &str,
327    database: Arc<Database>,
328) -> Result<Option<u64>, sqlx::Error> {
329    let Some(user) = AdminUser::find_by_username(username, &database).await? else {
330        return Ok(None);
331    };
332    Ok(Some(
333        AdminSession::delete_for_user(user.id, &database).await?,
334    ))
335}
336
337/// Deletes an operator, cascading to their sessions. `false` when there was no
338/// such user.
339///
340/// The bare form, for a caller with nobody to ask -- see the note above the
341/// deletes in [`crate::admin::ops`].
342pub async fn delete_user(username: &str, database: Arc<Database>) -> Result<bool, sqlx::Error> {
343    let Some(user) = AdminUser::find_by_username(username, &database).await? else {
344        return Ok(false);
345    };
346    AdminUser::delete(user.id, &database).await
347}
348
349/// What [`confirm_delete_user`] did.
350///
351/// [`DeleteOutcome`](crate::admin::ops::DeleteOutcome) less its `LiveCertificates` refusal: an operator holds no
352/// certificate, so a caller matching on that variant here had only an
353/// `unreachable!` to put in the arm.
354#[derive(Debug, PartialEq, Eq)]
355pub enum UserDeleteOutcome {
356    NotFound,
357    Cancelled,
358    /// Deleted, carrying the sessions that cascaded with it.
359    Deleted(crate::admin::ops::Deleted),
360}
361
362/// [`delete_user`], asking first and naming what goes with it.
363pub async fn confirm_delete_user(
364    username: &str,
365    assume_yes: bool,
366    reader: &mut impl BufRead,
367    database: Arc<Database>,
368) -> Result<UserDeleteOutcome, sqlx::Error> {
369    let Some(user) = AdminUser::find_by_username(username, &database).await? else {
370        return Ok(UserDeleteOutcome::NotFound);
371    };
372    let sessions = AdminSession::list_all(Some(user.id), &database)
373        .await?
374        .len();
375    let prompt = format!(
376        "Delete admin user {} (status: {}, {sessions} session(s) will cascade)?",
377        user.username, user.status
378    );
379    if !confirm(&prompt, assume_yes, reader) {
380        return Ok(UserDeleteOutcome::Cancelled);
381    }
382    AdminUser::delete(user.id, &database).await?;
383    Ok(UserDeleteOutcome::Deleted(crate::admin::ops::Deleted {
384        cascaded: sessions as u64,
385    }))
386}
387
388/// Checks a username and password, re-hashing the stored digest if it was
389/// written under parameters this build has moved past.
390///
391/// **The KDF runs even when the username is unknown**, against
392/// [`password::dummy_hash`]. Without that, an unknown user answers in
393/// microseconds and a known one in a quarter-second, and login latency
394/// enumerates the operator table.
395///
396/// Does **not** stamp `last_login_at` or create a session: a login is not
397/// complete until a session exists, which for a user with a second factor is
398/// two requests away. The caller decides when that happened.
399pub async fn authenticate(
400    username: &str,
401    plaintext: &str,
402    database: Arc<Database>,
403) -> Result<AuthOutcome, sqlx::Error> {
404    let Some(mut user) = AdminUser::find_by_username(username, &database).await? else {
405        // Deliberately discarded: the point is the time it took.
406        let _ = password::verify_password_off_runtime(password::dummy_hash(), plaintext).await;
407        return Ok(AuthOutcome::UnknownUser);
408    };
409
410    let verified = match password::verify_password_off_runtime(&user.password_hash, plaintext).await
411    {
412        Ok(verified) => verified,
413        Err(error) => {
414            // A corrupt row is not a wrong password. Refuse the login, but say
415            // so loudly -- nobody will ever guess this from a 401, and the
416            // account is unusable until `admin user passwd` rewrites it.
417            warn!(event = "admin_password_hash_unreadable",
418                  outcome = "failure",
419                  user_id = %user.id,
420                  username = %user.username,
421                  error = %error,
422                  "stored password hash could not be decoded; \
423                   run `acme-proxy admin user passwd` to rewrite it");
424            return Ok(AuthOutcome::WrongPassword(Box::new(user)));
425        }
426    };
427
428    if !verified {
429        return Ok(AuthOutcome::WrongPassword(Box::new(user)));
430    }
431    if !user.is_active() {
432        return Ok(AuthOutcome::Disabled(Box::new(user)));
433    }
434
435    // The one place a stored hash is ever upgraded. Doing it here, on a
436    // verified password, is the only moment the plaintext is available to
437    // re-derive from.
438    if password::needs_rehash(&user.password_hash)
439        && let Some(rehashed) = password::hash_password_off_runtime(plaintext).await
440    {
441        user.set_password_hash(&rehashed, &database).await?;
442        info!(event = "admin_password_rehashed", outcome = "success", user_id = %user.id);
443    }
444
445    Ok(AuthOutcome::Authenticated(Box::new(user)))
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use acme_proxy_store::admin_session::NewSession;
452
453    const GOOD: &str = "a-long-enough-password";
454
455    async fn db() -> Arc<Database> {
456        Arc::new(Database::connect_in_memory().await.unwrap())
457    }
458
459    /// Writes a row whose hash is cheap, so the tests that only care about
460    /// *which branch* `authenticate` takes do not each pay 600 000 iterations.
461    /// The stored form is the real one; only the cost differs.
462    async fn user_with_cheap_password(
463        username: &str,
464        plaintext: &str,
465        database: Arc<Database>,
466    ) -> AdminUser {
467        // 1 iteration, but encoded exactly as production encodes it -- which
468        // also means `needs_rehash` reports true, so any test using this and
469        // then authenticating successfully is exercising the rehash path.
470        let salt = [3u8; 16];
471        let mut digest = [0u8; 32];
472        ring::pbkdf2::derive(
473            ring::pbkdf2::PBKDF2_HMAC_SHA256,
474            std::num::NonZeroU32::new(1).unwrap(),
475            &salt,
476            plaintext.as_bytes(),
477            &mut digest,
478        );
479        let encoded = format!(
480            "pbkdf2-sha256$1${}${}",
481            base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, salt),
482            base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, digest),
483        );
484        AdminUser::create(username, &encoded, None, &database)
485            .await
486            .unwrap()
487    }
488
489    #[tokio::test]
490    async fn set_contact_email_stores_clears_and_validates() {
491        let db = db().await;
492        create_user("alice", GOOD, &PasswordContext::empty(), None, db.clone())
493            .await
494            .unwrap();
495
496        // Unknown operator -> None.
497        assert!(
498            set_contact_email("nobody", Some("x@example.com"), db.clone())
499                .await
500                .unwrap()
501                .is_none()
502        );
503
504        // A malformed address is refused here, not on delivery.
505        let error = set_contact_email("alice", Some("not an address"), db.clone())
506            .await
507            .unwrap_err();
508        assert!(matches!(error, UserError::InvalidContact(_)), "{error}");
509
510        // A good address is stored and synced.
511        let user = set_contact_email("alice", Some("  alice@example.com  "), db.clone())
512            .await
513            .unwrap()
514            .unwrap();
515        assert_eq!(user.contact_email.as_deref(), Some("alice@example.com"));
516
517        // An empty value clears it, as does `None`.
518        for cleared in [Some("  "), None] {
519            let user = set_contact_email("alice", cleared, db.clone())
520                .await
521                .unwrap()
522                .unwrap();
523            assert_eq!(user.contact_email, None);
524            set_contact_email("alice", Some("alice@example.com"), db.clone())
525                .await
526                .unwrap();
527        }
528    }
529
530    #[tokio::test]
531    async fn create_user_normalizes_and_hashes() {
532        let db = db().await;
533        let user = create_user(
534            "  Alice ",
535            GOOD,
536            &PasswordContext::empty(),
537            None,
538            db.clone(),
539        )
540        .await
541        .unwrap();
542        assert_eq!(user.username, "alice");
543        assert!(user.is_active());
544        // Stored one-way: the plaintext appears nowhere.
545        assert!(!user.password_hash.contains(GOOD));
546        assert_eq!(
547            password::verify_password(&user.password_hash, GOOD),
548            Ok(true)
549        );
550    }
551
552    #[tokio::test]
553    async fn create_user_refuses_a_password_below_the_policy() {
554        let db = db().await;
555        let error = create_user(
556            "alice",
557            "short",
558            &PasswordContext::empty(),
559            None,
560            db.clone(),
561        )
562        .await
563        .unwrap_err();
564        assert!(matches!(error, UserError::Policy(_)));
565        assert!(error.to_string().contains("at least 12"));
566        // Nothing was written.
567        assert_eq!(list_users(50, 0, db).await.unwrap().1, 0);
568    }
569
570    /// The other two policy rules reach this layer through the same
571    /// `UserError::Policy`, and leave the table as untouched as the length
572    /// rule does.
573    #[tokio::test]
574    async fn create_user_refuses_a_common_password_and_a_deployment_word() {
575        let db = db().await;
576
577        let error = create_user(
578            "alice",
579            "passwordpassword",
580            &PasswordContext::empty(),
581            None,
582            db.clone(),
583        )
584        .await
585        .unwrap_err();
586        assert!(matches!(error, UserError::Policy(_)));
587        assert!(error.to_string().contains("commonly used"));
588
589        let mut config = acme_proxy_core::config::Config::default();
590        config.server.base_url = "https://ca.example.com".to_string();
591        let context = PasswordContext::from_config(&config, "alice");
592        let error = create_user("alice", "acmeproxy2026!!", &context, None, db.clone())
593            .await
594            .unwrap_err();
595        assert!(matches!(error, UserError::Policy(_)));
596        assert!(error.to_string().contains("names this deployment"));
597
598        // Neither attempt wrote a row.
599        assert_eq!(list_users(50, 0, db).await.unwrap().1, 0);
600    }
601
602    /// `set_password` checks the policy before the user lookup, so all three
603    /// rules answer the same way for a name that does not exist.
604    #[tokio::test]
605    async fn set_password_refuses_a_common_password_before_looking_the_user_up() {
606        let db = db().await;
607        let error = set_password(
608            "nobody",
609            "passwordpassword",
610            &PasswordContext::empty(),
611            db.clone(),
612        )
613        .await
614        .unwrap_err();
615        assert!(error.to_string().contains("commonly used"), "got: {error}");
616    }
617
618    #[tokio::test]
619    async fn create_user_refuses_an_empty_username() {
620        let db = db().await;
621        let error = create_user("   ", GOOD, &PasswordContext::empty(), None, db)
622            .await
623            .unwrap_err();
624        assert!(error.to_string().contains("username must not be empty"));
625    }
626
627    #[tokio::test]
628    async fn create_user_refuses_a_duplicate_in_words_not_a_unique_violation() {
629        let db = db().await;
630        create_user("alice", GOOD, &PasswordContext::empty(), None, db.clone())
631            .await
632            .unwrap();
633        let error = create_user("ALICE", GOOD, &PasswordContext::empty(), None, db)
634            .await
635            .unwrap_err();
636        assert!(matches!(error, UserError::DuplicateUsername(_)));
637        assert_eq!(
638            error.to_string(),
639            "an admin user named `alice` already exists"
640        );
641    }
642
643    #[tokio::test]
644    async fn set_password_revokes_every_session_of_that_user() {
645        let db = db().await;
646        let user = user_with_cheap_password("alice", "old-password", db.clone()).await;
647        AdminSession::create(
648            NewSession {
649                user_id: user.id,
650                token_hash: "hash-a",
651                csrf_token: "csrf",
652                created_ip: None,
653                user_agent: None,
654            },
655            std::time::Duration::from_secs(60),
656            &db,
657        )
658        .await
659        .unwrap();
660
661        assert!(
662            set_password("alice", GOOD, &PasswordContext::empty(), db.clone())
663                .await
664                .unwrap()
665                .is_some()
666        );
667        assert!(
668            AdminSession::list_all(Some(user.id), &db)
669                .await
670                .unwrap()
671                .is_empty(),
672            "a password change that left sessions alive would be a change in name only"
673        );
674
675        let reloaded = AdminUser::find_by_username("alice", &db)
676            .await
677            .unwrap()
678            .unwrap();
679        assert_eq!(
680            password::verify_password(&reloaded.password_hash, GOOD),
681            Ok(true)
682        );
683    }
684
685    /// The self-service counterpart to `set_password_revokes_every_session_of_that_user`:
686    /// the same revocation, except the caller's own session is the one
687    /// exemption. A panel that signed its own operator out mid-edit would be
688    /// indistinguishable from a bug in the form.
689    #[tokio::test]
690    async fn change_own_password_keeps_the_calling_session_and_drops_every_other() {
691        let db = db().await;
692        let mut user = user_with_cheap_password("alice", "old-password", db.clone()).await;
693
694        let kept = AdminSession::create(
695            NewSession {
696                user_id: user.id,
697                token_hash: "kept-hash",
698                csrf_token: "csrf",
699                created_ip: None,
700                user_agent: None,
701            },
702            std::time::Duration::from_secs(3600),
703            &db,
704        )
705        .await
706        .unwrap();
707        AdminSession::create(
708            NewSession {
709                user_id: user.id,
710                token_hash: "other-hash",
711                csrf_token: "csrf",
712                created_ip: None,
713                user_agent: None,
714            },
715            std::time::Duration::from_secs(3600),
716            &db,
717        )
718        .await
719        .unwrap();
720
721        change_own_password(
722            &mut user,
723            GOOD,
724            &PasswordContext::empty(),
725            &kept.token_hash,
726            db.clone(),
727        )
728        .await
729        .unwrap();
730
731        let live = AdminSession::list_all(Some(user.id), &db).await.unwrap();
732        assert_eq!(live.len(), 1, "every other session must be revoked");
733        assert_eq!(live[0].token_hash, "kept-hash");
734
735        let reloaded = AdminUser::find_by_username("alice", &db)
736            .await
737            .unwrap()
738            .unwrap();
739        assert_eq!(
740            password::verify_password(&reloaded.password_hash, GOOD),
741            Ok(true)
742        );
743        assert_eq!(
744            password::verify_password(&reloaded.password_hash, "old-password"),
745            Ok(false)
746        );
747    }
748
749    /// The policy still runs, and a rejected password writes nothing -- the
750    /// same rule `set_password` and `create_user` both hold.
751    #[tokio::test]
752    async fn change_own_password_checks_the_policy() {
753        let db = db().await;
754        let mut user = user_with_cheap_password("alice", "old-password", db.clone()).await;
755        let before = user.password_hash.clone();
756
757        let error = change_own_password(
758            &mut user,
759            "short",
760            &PasswordContext::empty(),
761            "kept-hash",
762            db.clone(),
763        )
764        .await
765        .unwrap_err();
766        assert!(matches!(error, UserError::Policy(_)));
767
768        let reloaded = AdminUser::find_by_username("alice", &db)
769            .await
770            .unwrap()
771            .unwrap();
772        assert_eq!(reloaded.password_hash, before);
773    }
774
775    #[tokio::test]
776    async fn set_password_of_an_unknown_user_is_none_and_checks_the_policy_first() {
777        let db = db().await;
778        assert!(
779            set_password("nobody", GOOD, &PasswordContext::empty(), db.clone())
780                .await
781                .unwrap()
782                .is_none()
783        );
784        // The policy is checked before the lookup, so a bad password for an
785        // unknown user is a policy error rather than a silent `None`.
786        assert!(matches!(
787            set_password("nobody", "short", &PasswordContext::empty(), db)
788                .await
789                .unwrap_err(),
790            UserError::Policy(_)
791        ));
792    }
793
794    #[tokio::test]
795    async fn disabling_a_user_drops_their_sessions_but_enabling_does_not() {
796        let db = db().await;
797        let user = user_with_cheap_password("alice", "pw", db.clone()).await;
798        AdminSession::create(
799            NewSession {
800                user_id: user.id,
801                token_hash: "hash-a",
802                csrf_token: "csrf",
803                created_ip: None,
804                user_agent: None,
805            },
806            std::time::Duration::from_secs(60),
807            &db,
808        )
809        .await
810        .unwrap();
811
812        set_status("alice", AdminStatus::Disabled, db.clone())
813            .await
814            .unwrap();
815        assert!(
816            AdminSession::list_all(Some(user.id), &db)
817                .await
818                .unwrap()
819                .is_empty()
820        );
821
822        // Re-enabling is just a status change; there is nothing to drop.
823        let reenabled = set_status("alice", AdminStatus::Active, db.clone())
824            .await
825            .unwrap()
826            .unwrap();
827        assert!(reenabled.0.is_active());
828        assert!(
829            set_status("nobody", AdminStatus::Disabled, db)
830                .await
831                .unwrap()
832                .is_none()
833        );
834    }
835
836    #[tokio::test]
837    async fn set_role_changes_the_tier_and_revokes_every_session() {
838        let db = db().await;
839        let user = user_with_cheap_password("alice", "pw", db.clone()).await;
840        assert_eq!(user.role(), AdminRole::Admin, "a fresh row reads as admin");
841        // A second admin, so demoting the first is not the last-admin case --
842        // that has its own test below.
843        user_with_cheap_password("root", "pw", db.clone()).await;
844        AdminSession::create(
845            NewSession {
846                user_id: user.id,
847                token_hash: "hash-a",
848                csrf_token: "csrf",
849                created_ip: None,
850                user_agent: None,
851            },
852            std::time::Duration::from_secs(60),
853            &db,
854        )
855        .await
856        .unwrap();
857
858        let updated = set_role("alice", AdminRole::Viewer, db.clone())
859            .await
860            .unwrap()
861            .unwrap();
862        assert_eq!(updated.0.role(), AdminRole::Viewer);
863        assert!(
864            AdminSession::list_all(Some(user.id), &db)
865                .await
866                .unwrap()
867                .is_empty(),
868            "a demotion that left an admin session live would take effect only on expiry"
869        );
870
871        assert!(
872            set_role("nobody", AdminRole::Operator, db)
873                .await
874                .unwrap()
875                .is_none()
876        );
877    }
878
879    /// `/operators/*` is `admin`-only on both web surfaces, so a deployment
880    /// with no admin cannot manage operators from the panel at all. Demoting
881    /// the last one is refused rather than discovered.
882    #[tokio::test]
883    async fn demoting_the_last_admin_is_refused() {
884        let db = db().await;
885        user_with_cheap_password("alice", "pw", db.clone()).await;
886
887        let error = set_role("alice", AdminRole::Viewer, db.clone())
888            .await
889            .expect_err("the only admin cannot be demoted");
890        assert!(error.to_string().contains("only admin"), "{error}");
891        // And nothing moved.
892        assert_eq!(
893            AdminUser::find_by_username("alice", &db)
894                .await
895                .unwrap()
896                .unwrap()
897                .role(),
898            AdminRole::Admin
899        );
900
901        // With a colleague at the same tier, the same call goes through.
902        user_with_cheap_password("root", "pw", db.clone()).await;
903        let (updated, _) = set_role("alice", AdminRole::Viewer, db.clone())
904            .await
905            .unwrap()
906            .unwrap();
907        assert_eq!(updated.role(), AdminRole::Viewer);
908
909        // Promoting is never refused, whoever is left.
910        assert!(set_role("alice", AdminRole::Admin, db).await.is_ok());
911    }
912
913    #[tokio::test]
914    async fn revoke_sessions_counts_what_it_removed() {
915        let db = db().await;
916        let user = user_with_cheap_password("alice", "pw", db.clone()).await;
917        for hash in ["a", "b"] {
918            AdminSession::create(
919                NewSession {
920                    user_id: user.id,
921                    token_hash: hash,
922                    csrf_token: "csrf",
923                    created_ip: None,
924                    user_agent: None,
925                },
926                std::time::Duration::from_secs(60),
927                &db,
928            )
929            .await
930            .unwrap();
931        }
932
933        assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(2));
934        assert_eq!(revoke_sessions("alice", db.clone()).await.unwrap(), Some(0));
935        assert_eq!(revoke_sessions("nobody", db).await.unwrap(), None);
936    }
937
938    #[tokio::test]
939    async fn delete_user_removes_the_row_and_reports_whether_it_existed() {
940        let db = db().await;
941        user_with_cheap_password("alice", "pw", db.clone()).await;
942        assert!(delete_user("alice", db.clone()).await.unwrap());
943        assert!(!delete_user("alice", db).await.unwrap());
944    }
945
946    #[tokio::test]
947    async fn confirm_delete_user_covers_its_three_outcomes() {
948        let db = db().await;
949        let mut empty: &[u8] = &[];
950        assert_eq!(
951            confirm_delete_user("nobody", true, &mut empty, db.clone())
952                .await
953                .unwrap(),
954            UserDeleteOutcome::NotFound
955        );
956
957        user_with_cheap_password("alice", "pw", db.clone()).await;
958        let mut no = b"n\n".as_slice();
959        assert_eq!(
960            confirm_delete_user("alice", false, &mut no, db.clone())
961                .await
962                .unwrap(),
963            UserDeleteOutcome::Cancelled
964        );
965        assert!(
966            AdminUser::find_by_username("alice", &db)
967                .await
968                .unwrap()
969                .is_some()
970        );
971
972        let mut empty: &[u8] = &[];
973        assert_eq!(
974            confirm_delete_user("alice", true, &mut empty, db.clone())
975                .await
976                .unwrap(),
977            UserDeleteOutcome::Deleted(crate::admin::ops::Deleted { cascaded: 0 })
978        );
979        assert!(
980            AdminUser::find_by_username("alice", &db)
981                .await
982                .unwrap()
983                .is_none()
984        );
985    }
986
987    #[tokio::test]
988    async fn authenticate_accepts_the_right_password() {
989        let db = db().await;
990        user_with_cheap_password("alice", "the-password", db.clone()).await;
991        let outcome = authenticate("alice", "the-password", db).await.unwrap();
992        assert!(matches!(outcome, AuthOutcome::Authenticated(_)));
993    }
994
995    #[tokio::test]
996    async fn authenticate_distinguishes_its_failures_for_the_log() {
997        let db = db().await;
998        user_with_cheap_password("alice", "the-password", db.clone()).await;
999
1000        assert!(matches!(
1001            authenticate("alice", "wrong", db.clone()).await.unwrap(),
1002            AuthOutcome::WrongPassword(_)
1003        ));
1004        assert!(matches!(
1005            authenticate("nobody", "the-password", db.clone())
1006                .await
1007                .unwrap(),
1008            AuthOutcome::UnknownUser
1009        ));
1010
1011        set_status("alice", AdminStatus::Disabled, db.clone())
1012            .await
1013            .unwrap();
1014        assert!(matches!(
1015            authenticate("alice", "the-password", db).await.unwrap(),
1016            AuthOutcome::Disabled(_)
1017        ));
1018    }
1019
1020    #[tokio::test]
1021    async fn authenticate_is_case_insensitive_in_the_username() {
1022        let db = db().await;
1023        user_with_cheap_password("alice", "the-password", db.clone()).await;
1024        assert!(matches!(
1025            authenticate("ALICE", "the-password", db).await.unwrap(),
1026            AuthOutcome::Authenticated(_)
1027        ));
1028    }
1029
1030    #[tokio::test]
1031    async fn a_successful_login_rehashes_a_row_written_under_older_parameters() {
1032        let db = db().await;
1033        let before = user_with_cheap_password("alice", "the-password", db.clone()).await;
1034        assert!(password::needs_rehash(&before.password_hash));
1035
1036        let outcome = authenticate("alice", "the-password", db.clone())
1037            .await
1038            .unwrap();
1039        let AuthOutcome::Authenticated(user) = outcome else {
1040            panic!("expected a successful authentication");
1041        };
1042        assert!(!password::needs_rehash(&user.password_hash));
1043
1044        // Persisted, not just updated in memory -- and the new digest still
1045        // verifies against the same password.
1046        let reloaded = AdminUser::find_by_username("alice", &db)
1047            .await
1048            .unwrap()
1049            .unwrap();
1050        assert!(!password::needs_rehash(&reloaded.password_hash));
1051        assert_eq!(
1052            password::verify_password(&reloaded.password_hash, "the-password"),
1053            Ok(true)
1054        );
1055    }
1056
1057    #[tokio::test]
1058    async fn a_corrupt_stored_hash_refuses_the_login_rather_than_erroring() {
1059        let db = db().await;
1060        AdminUser::create("alice", "not-a-valid-encoded-hash", None, &db)
1061            .await
1062            .unwrap();
1063        // Not an Err: a mangled row must not take the whole login endpoint
1064        // down, and it must not read as "correct password" either.
1065        assert!(matches!(
1066            authenticate("alice", "anything", db).await.unwrap(),
1067            AuthOutcome::WrongPassword(_)
1068        ));
1069    }
1070
1071    #[test]
1072    fn every_user_error_renders() {
1073        assert!(
1074            UserError::Database(sqlx::Error::RowNotFound)
1075                .to_string()
1076                .starts_with("database error:")
1077        );
1078        assert_eq!(
1079            UserError::Policy("too short".to_string()).to_string(),
1080            "too short"
1081        );
1082        assert_eq!(
1083            UserError::DuplicateUsername("bob".to_string()).to_string(),
1084            "an admin user named `bob` already exists"
1085        );
1086    }
1087}