Skip to main content

acme_proxy/cli/
webadmin.rs

1//! `acme-proxy admin …` — the web admin's operators and their sessions.
2//!
3//! This is how the panel is bootstrapped: it has no sign-up page and never
4//! will, so the first operator is created here, from a shell on the host.
5//!
6//! ## The password never goes in argv
7//!
8//! There is deliberately no `--password` flag. argv is visible to every
9//! process on the host via `ps` and is routinely written to shell history —
10//! the same reasoning `upstream register` already applies to the EAB secret,
11//! and pinned by the same kind of negative test. A password arrives either
12//! through `--password-file` or on stdin.
13
14use std::io::{BufRead, IsTerminal};
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use clap::Subcommand;
19
20use crate::admin;
21use crate::admin::mfa;
22use crate::admin::ops::DeleteOutcome;
23use crate::admin::password::PasswordContext;
24use crate::admin::prompt::confirm;
25use crate::admin::users::{self, UserError};
26use crate::cli::CliError;
27use crate::cli::render;
28use crate::cli::style::Palette;
29use crate::cli::window::{DEFAULT_LIMIT, Window};
30use crate::config::Config;
31use crate::sqlite::admin_session::AdminSession;
32use crate::sqlite::admin_user::AdminUser;
33use crate::sqlite::db::Database;
34
35#[derive(Subcommand)]
36pub enum AdminCommand {
37    /// Manage the operators who can sign in to the web admin.
38    User {
39        #[command(subcommand)]
40        command: AdminUserCommand,
41    },
42    /// Inspect and revoke logged-in browser sessions.
43    Session {
44        #[command(subcommand)]
45        command: AdminSessionCommand,
46    },
47}
48
49#[derive(Subcommand)]
50pub enum AdminUserCommand {
51    /// Create an operator. The password is read from `--password-file`, or
52    /// from stdin.
53    Create {
54        username: String,
55        /// Read the password from this file instead of stdin. A single
56        /// trailing newline is stripped.
57        #[arg(long = "password-file")]
58        password_file: Option<PathBuf>,
59    },
60    /// List operators, oldest first. Never shows a password hash.
61    List {
62        #[arg(long, default_value_t = DEFAULT_LIMIT)]
63        limit: i64,
64        #[arg(long, default_value_t = 0)]
65        offset: i64,
66        #[arg(long)]
67        json: bool,
68    },
69    /// Show one operator, second factor included. Never shows a password hash.
70    Show {
71        username: String,
72        #[arg(long)]
73        json: bool,
74    },
75    /// Replace an operator's password, revoking every session they hold.
76    Passwd {
77        username: String,
78        #[arg(long = "password-file")]
79        password_file: Option<PathBuf>,
80    },
81    /// Delete an operator and every session of theirs.
82    Delete { username: String },
83    /// Bar an operator from signing in, dropping their current sessions.
84    Disable { username: String },
85    /// Undo `disable`.
86    Enable { username: String },
87    /// Inspect or remove an operator's second factor.
88    Totp {
89        #[command(subcommand)]
90        command: AdminUserTotpCommand,
91    },
92}
93
94/// The operator-side half of the second factor.
95///
96/// There is deliberately **no `enrol`** here, and the omission is the same one
97/// that keeps a password out of argv: there is no way to enrol from a terminal
98/// that does not put the base32 secret into scrollback and the shell's own
99/// history. The panel shows it once, behind `Cache-Control: no-store`, on a
100/// loopback listener. What a shell is for is the case the panel cannot serve --
101/// an operator who has lost the factor and so cannot sign in to fix it.
102#[derive(Subcommand)]
103pub enum AdminUserTotpCommand {
104    /// Whether an operator has a second factor, and how many recovery codes
105    /// are left.
106    Status {
107        username: String,
108        #[arg(long)]
109        json: bool,
110    },
111    /// Remove an operator's second factor and every recovery code, and revoke
112    /// their sessions.
113    ///
114    /// The lockout lever: a lost phone is a shell command on the host, not a
115    /// database edit. Asks first, because it takes a security control away.
116    Reset { username: String },
117    /// Mint a fresh set of recovery codes, printed once. The previous set stops
118    /// working immediately.
119    RecoveryCodes { username: String },
120}
121
122#[derive(Subcommand)]
123pub enum AdminSessionCommand {
124    /// List live sessions, newest first.
125    List {
126        /// Only this operator's sessions.
127        #[arg(long)]
128        username: Option<String>,
129        #[arg(long, default_value_t = DEFAULT_LIMIT)]
130        limit: i64,
131        #[arg(long, default_value_t = 0)]
132        offset: i64,
133        #[arg(long)]
134        json: bool,
135    },
136    /// Revoke sessions: one operator's, or everyone's.
137    Revoke {
138        #[arg(long, conflicts_with = "all")]
139        user: Option<String>,
140        /// Revoke every session on the server.
141        #[arg(long, conflicts_with = "user")]
142        all: bool,
143    },
144}
145
146pub async fn run_admin_command(
147    command: AdminCommand,
148    yes: bool,
149    palette: Palette,
150    reader: &mut impl BufRead,
151    config: &Config,
152    database: Arc<Database>,
153) -> Result<(), CliError> {
154    match command {
155        AdminCommand::User { command } => {
156            run_user_command(command, yes, palette, reader, config, database).await
157        }
158        AdminCommand::Session { command } => run_session_command(command, palette, database).await,
159    }
160}
161
162async fn run_user_command(
163    command: AdminUserCommand,
164    yes: bool,
165    palette: Palette,
166    reader: &mut impl BufRead,
167    config: &Config,
168    database: Arc<Database>,
169) -> Result<(), CliError> {
170    match command {
171        AdminUserCommand::Create {
172            username,
173            password_file,
174        } => {
175            let password = read_password(password_file.as_deref(), reader)?;
176            let context = PasswordContext::from_config(config, &username);
177            let user = users::create_user(&username, &password, &context, database)
178                .await
179                .map_err(user_error)?;
180            // The id, not the password: nothing echoes a credential back.
181            println!("Created admin user {} ({}).", user.username, user.id);
182        }
183        AdminUserCommand::List {
184            limit,
185            offset,
186            json,
187        } => {
188            let window = Window::resolve(limit, offset);
189            let (users, total) = users::list_users(window.limit, window.offset, database).await?;
190            render::print_page(
191                &users,
192                total,
193                window,
194                json,
195                admin::render_admin_user_json,
196                |user| render::render_admin_user_line(user, palette),
197            );
198        }
199        AdminUserCommand::Show { username, json } => {
200            // The same pair `totp status` reads, for the reason
201            // `render_admin_user_detail_json` records: the enrolment state and
202            // the code count are what a listing cannot carry, and they are the
203            // half of an operator's row that decides whether they can sign in.
204            let user = find_user(&username, database.clone()).await?;
205            let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
206
207            if json {
208                println!("{}", admin::render_admin_user_detail_json(&user, remaining));
209            } else {
210                print!(
211                    "{}",
212                    render::render_admin_user_detail_text(&user, remaining, palette)
213                );
214            }
215        }
216        AdminUserCommand::Passwd {
217            username,
218            password_file,
219        } => {
220            let password = read_password(password_file.as_deref(), reader)?;
221            let context = PasswordContext::from_config(config, &username);
222            match users::set_password(&username, &password, &context, database)
223                .await
224                .map_err(user_error)?
225            {
226                None => return Err(not_found(&username)),
227                Some(user) => println!(
228                    "Password changed for {}. Every session they held was revoked.",
229                    user.username
230                ),
231            }
232        }
233        AdminUserCommand::Delete { username } => {
234            match users::confirm_delete_user(&username, yes, reader, database).await? {
235                DeleteOutcome::NotFound => return Err(not_found(&username)),
236                DeleteOutcome::Cancelled => println!("Cancelled."),
237                DeleteOutcome::Deleted => println!("Deleted admin user {username}."),
238            }
239        }
240        AdminUserCommand::Disable { username } => {
241            set_status_or_not_found(&username, "disabled", database).await?;
242            println!("Disabled {username}. Their sessions were revoked.");
243        }
244        AdminUserCommand::Enable { username } => {
245            set_status_or_not_found(&username, "active", database).await?;
246            println!("Enabled {username}.");
247        }
248        AdminUserCommand::Totp { command } => {
249            run_totp_command(command, yes, palette, reader, database).await?;
250        }
251    }
252    Ok(())
253}
254
255async fn run_totp_command(
256    command: AdminUserTotpCommand,
257    yes: bool,
258    palette: Palette,
259    reader: &mut impl BufRead,
260    database: Arc<Database>,
261) -> Result<(), CliError> {
262    match command {
263        AdminUserTotpCommand::Status { username, json } => {
264            let user = find_user(&username, database.clone()).await?;
265            let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
266
267            if json {
268                println!(
269                    "{}",
270                    serde_json::json!({
271                        "username": user.username,
272                        "totpEnabled": user.has_totp(),
273                        "enrolmentPending": user.has_pending_totp(),
274                        "recoveryCodesRemaining": remaining,
275                    })
276                );
277            } else {
278                println!(
279                    "{}",
280                    render::render_admin_totp_line(&user, remaining, palette)
281                );
282            }
283        }
284        AdminUserTotpCommand::Reset { username } => {
285            let mut user = find_user(&username, database.clone()).await?;
286            if !user.has_totp() && !user.has_pending_totp() {
287                println!("{} has no second factor; nothing to reset.", user.username);
288                return Ok(());
289            }
290
291            let prompt = format!(
292                "Remove the second factor and every recovery code for {}, \
293                 and revoke their sessions?",
294                user.username
295            );
296            if !confirm(&prompt, yes, reader) {
297                println!("Cancelled.");
298                return Ok(());
299            }
300
301            // `None`: this is a change made on the operator's behalf, from a
302            // shell they are not signed in from, so there is no session to keep.
303            mfa::disable_totp(&mut user, None, database).await?;
304            println!(
305                "Removed the second factor for {}. Their sessions were revoked; \
306                 they can sign in with a password alone until they enrol again.",
307                user.username
308            );
309        }
310        AdminUserTotpCommand::RecoveryCodes { username } => {
311            let user = find_user(&username, database.clone()).await?;
312            if !user.has_totp() {
313                return Err(CliError(format!(
314                    "{} has no second factor, so recovery codes would recover nothing: \
315                     enrol from the panel first",
316                    user.username
317                )));
318            }
319
320            let codes = mfa::regenerate_recovery_codes(&user, database).await?;
321            // The `eab create` treatment: printed once, stored one-way, and the
322            // previous set is already dead by the time this prints.
323            println!(
324                "New recovery codes for {} — the previous set no longer works.\n\
325                 Store these now; they are not recoverable.\n",
326                user.username
327            );
328            for code in &codes {
329                println!("  {code}");
330            }
331        }
332    }
333    Ok(())
334}
335
336/// Resolves a username, reporting an unknown one in words rather than as a
337/// silent no-op.
338async fn find_user(username: &str, database: Arc<Database>) -> Result<AdminUser, CliError> {
339    AdminUser::find_by_username(username, &database)
340        .await?
341        .ok_or_else(|| not_found(username))
342}
343
344async fn run_session_command(
345    command: AdminSessionCommand,
346    palette: Palette,
347    database: Arc<Database>,
348) -> Result<(), CliError> {
349    match command {
350        AdminSessionCommand::List {
351            username,
352            limit,
353            offset,
354            json,
355        } => {
356            // Resolved to an id first: `admin_sessions` carries the user id,
357            // and an unknown name must say so rather than quietly listing
358            // every session on the server.
359            let user_id = match username.as_deref() {
360                None => None,
361                Some(name) => match AdminUser::find_by_username(name, &database).await? {
362                    None => return Err(not_found(name)),
363                    Some(user) => Some(user.id),
364                },
365            };
366
367            let window = Window::resolve(limit, offset);
368            let (sessions, total) =
369                AdminSession::search(user_id, window.limit, window.offset, &database).await?;
370            render::print_page(
371                &sessions,
372                total,
373                window,
374                json,
375                admin::render_admin_session_json,
376                |session| render::render_admin_session_line(session, palette),
377            );
378        }
379        AdminSessionCommand::Revoke { user, all } => match (user, all) {
380            (Some(username), _) => match users::revoke_sessions(&username, database).await? {
381                None => return Err(not_found(&username)),
382                Some(count) => println!("Revoked {count} session(s) for {username}."),
383            },
384            (None, true) => {
385                let count = AdminSession::delete_all(&database).await?;
386                println!("Revoked {count} session(s).");
387            }
388            (None, false) => {
389                return Err(CliError(
390                    "say whose sessions to revoke: --user <username>, or --all".to_string(),
391                ));
392            }
393        },
394    }
395    Ok(())
396}
397
398async fn set_status_or_not_found(
399    username: &str,
400    status: &str,
401    database: Arc<Database>,
402) -> Result<(), CliError> {
403    if users::set_status(username, status, database)
404        .await?
405        .is_none()
406    {
407        return Err(not_found(username));
408    }
409    Ok(())
410}
411
412/// Reads a password from a file, or one line of `reader`.
413///
414/// The file form strips a single trailing newline, so
415/// `printf '%s\n' "$pw" > file` and `printf '%s' "$pw" > file` mean the same
416/// thing — an operator should not have to know which their editor wrote.
417fn read_password(
418    path: Option<&std::path::Path>,
419    reader: &mut impl BufRead,
420) -> Result<String, CliError> {
421    match path {
422        Some(path) => {
423            let raw = std::fs::read_to_string(path)
424                .map_err(|error| CliError(format!("cannot read {}: {error}", path.display())))?;
425            Ok(raw.strip_suffix('\n').unwrap_or(&raw).to_string())
426        }
427        None => {
428            // No `rpassword`: echo suppression needs a real TTY, which would
429            // break the injectable-reader testability this whole layer is
430            // built on. Warn instead, and point at the flag that avoids it.
431            if std::io::stdin().is_terminal() {
432                eprintln!(
433                    "Note: the password will be echoed. Use --password-file, or pipe it in:\n  \
434                     printf '%s' \"$password\" | acme-proxy admin user create <username>"
435                );
436            }
437            eprintln!("Enter the password, then press Enter:");
438            let mut line = String::new();
439            if reader.read_line(&mut line).unwrap_or(0) == 0 {
440                return Err(CliError("no password supplied".to_string()));
441            }
442            // Only the line terminator, never surrounding whitespace: a
443            // password may legitimately begin or end with a space.
444            let password = line.strip_suffix('\n').unwrap_or(&line);
445            let password = password.strip_suffix('\r').unwrap_or(password);
446            Ok(password.to_string())
447        }
448    }
449}
450
451fn user_error(error: UserError) -> CliError {
452    match error {
453        UserError::Database(error) => CliError::from(error),
454        other => CliError(other.to_string()),
455    }
456}
457
458fn not_found(username: &str) -> CliError {
459    CliError(format!("no such admin user: {username}"))
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::sqlite::admin_session::NewSession;
466    use crate::testutil::TempDir;
467
468    const GOOD: &str = "a-long-enough-password";
469
470    async fn db() -> Arc<Database> {
471        Arc::new(Database::connect_in_memory().await.unwrap())
472    }
473
474    /// Runs a command with a stdin that supplies `input`, against a default
475    /// configuration.
476    async fn run(
477        command: AdminCommand,
478        input: &str,
479        database: Arc<Database>,
480    ) -> Result<(), CliError> {
481        run_with_config(command, input, &Config::default(), database).await
482    }
483
484    /// [`run`] with the configuration spelled out, for the tests that care
485    /// what [`PasswordContext::from_config`] derived from it.
486    async fn run_with_config(
487        command: AdminCommand,
488        input: &str,
489        config: &Config,
490        database: Arc<Database>,
491    ) -> Result<(), CliError> {
492        let mut reader = input.as_bytes();
493        run_admin_command(
494            command,
495            true,
496            Palette::plain(),
497            &mut reader,
498            config,
499            database,
500        )
501        .await
502    }
503
504    fn create(username: &str) -> AdminCommand {
505        AdminCommand::User {
506            command: AdminUserCommand::Create {
507                username: username.to_string(),
508                password_file: None,
509            },
510        }
511    }
512
513    #[tokio::test]
514    async fn create_reads_the_password_from_stdin() {
515        let db = db().await;
516        run(create("alice"), &format!("{GOOD}\n"), db.clone())
517            .await
518            .unwrap();
519
520        let user = AdminUser::find_by_username("alice", &db)
521            .await
522            .unwrap()
523            .unwrap();
524        assert!(user.is_active());
525        assert_eq!(
526            admin::password::verify_password(&user.password_hash, GOOD),
527            Ok(true)
528        );
529    }
530
531    #[tokio::test]
532    async fn create_reads_the_password_from_a_file_and_strips_one_newline() {
533        let dir = TempDir::new("admin-passwd");
534        let path = dir.join("pw");
535        std::fs::write(&path, format!("{GOOD}\n")).unwrap();
536
537        let db = db().await;
538        run(
539            AdminCommand::User {
540                command: AdminUserCommand::Create {
541                    username: "alice".to_string(),
542                    password_file: Some(path),
543                },
544            },
545            "",
546            db.clone(),
547        )
548        .await
549        .unwrap();
550
551        let user = AdminUser::find_by_username("alice", &db)
552            .await
553            .unwrap()
554            .unwrap();
555        assert_eq!(
556            admin::password::verify_password(&user.password_hash, GOOD),
557            Ok(true),
558            "the trailing newline must not be part of the password"
559        );
560    }
561
562    #[tokio::test]
563    async fn create_refuses_a_missing_password_file() {
564        let db = db().await;
565        let error = run(
566            AdminCommand::User {
567                command: AdminUserCommand::Create {
568                    username: "alice".to_string(),
569                    password_file: Some(PathBuf::from("/nonexistent/pw")),
570                },
571            },
572            "",
573            db,
574        )
575        .await
576        .unwrap_err();
577        assert!(error.0.starts_with("cannot read /nonexistent/pw"));
578    }
579
580    /// The words the *configuration* produced have to reach the terminal, or
581    /// the operator is told their password is unacceptable and not why. This
582    /// is also the only test that proves `dispatch`'s `&Config` is threaded
583    /// all the way to `PasswordContext::from_config` rather than dropped.
584    #[tokio::test]
585    async fn create_surfaces_the_context_and_corpus_rules_in_words() {
586        let db = db().await;
587
588        let error = run(create("alice"), "passwordpassword\n", db.clone())
589            .await
590            .unwrap_err();
591        assert!(error.0.contains("commonly used"), "got: {}", error.0);
592
593        let mut config = Config::default();
594        config.server.base_url = "https://ca.contoso.example".to_string();
595        let error = run_with_config(
596            create("alice"),
597            "contoso-is-my-password\n",
598            &config,
599            db.clone(),
600        )
601        .await
602        .unwrap_err();
603        assert!(error.0.contains("contoso"), "got: {}", error.0);
604        assert!(
605            error.0.contains("names this deployment"),
606            "got: {}",
607            error.0
608        );
609
610        // Neither attempt created anything.
611        assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
612    }
613
614    #[tokio::test]
615    async fn create_refuses_empty_stdin() {
616        let db = db().await;
617        let error = run(create("alice"), "", db).await.unwrap_err();
618        assert_eq!(error, CliError("no password supplied".to_string()));
619    }
620
621    #[tokio::test]
622    async fn create_surfaces_the_policy_and_duplicate_errors_in_words() {
623        let db = db().await;
624        let error = run(create("alice"), "short\n", db.clone())
625            .await
626            .unwrap_err();
627        assert!(error.0.contains("at least 12"), "got: {}", error.0);
628
629        run(create("alice"), &format!("{GOOD}\n"), db.clone())
630            .await
631            .unwrap();
632        let error = run(create("ALICE"), &format!("{GOOD}\n"), db)
633            .await
634            .unwrap_err();
635        assert_eq!(
636            error,
637            CliError("an admin user named `alice` already exists".to_string())
638        );
639    }
640
641    #[tokio::test]
642    async fn passwd_changes_the_password_and_reports_an_unknown_user() {
643        let db = db().await;
644        run(create("alice"), &format!("{GOOD}\n"), db.clone())
645            .await
646            .unwrap();
647
648        let passwd = |username: &str| AdminCommand::User {
649            command: AdminUserCommand::Passwd {
650                username: username.to_string(),
651                password_file: None,
652            },
653        };
654
655        run(passwd("alice"), "another-long-password\n", db.clone())
656            .await
657            .unwrap();
658        let user = AdminUser::find_by_username("alice", &db)
659            .await
660            .unwrap()
661            .unwrap();
662        assert_eq!(
663            admin::password::verify_password(&user.password_hash, "another-long-password"),
664            Ok(true)
665        );
666
667        let error = run(passwd("nobody"), &format!("{GOOD}\n"), db)
668            .await
669            .unwrap_err();
670        assert_eq!(error, CliError("no such admin user: nobody".to_string()));
671    }
672
673    /// The detail an operator's row could not carry: the enrolment state and
674    /// the recovery-code count. Both shapes, and an unknown name refused in
675    /// words rather than answered with an empty object.
676    #[tokio::test]
677    async fn show_reports_the_row_and_the_second_factor() {
678        let db = db().await;
679        run(create("alice"), &format!("{GOOD}\n"), db.clone())
680            .await
681            .unwrap();
682
683        let show = |username: &str, json: bool| AdminCommand::User {
684            command: AdminUserCommand::Show {
685                username: username.to_string(),
686                json,
687            },
688        };
689        for json in [true, false] {
690            run(show("alice", json), "", db.clone()).await.unwrap();
691        }
692        assert_eq!(
693            run(show("nobody", false), "", db.clone())
694                .await
695                .unwrap_err(),
696            CliError("no such admin user: nobody".to_string())
697        );
698
699        // The three states the detail shape distinguishes, walked in order: no
700        // factor, enrolment started, confirmed. `totpEnabled` alone cannot tell
701        // the first two apart, which is why `enrolmentPending` is on this shape
702        // and not on the listing's.
703        let user = AdminUser::find_by_username("alice", &db)
704            .await
705            .unwrap()
706            .unwrap();
707        let rendered = admin::render_admin_user_detail_json(&user, 0);
708        assert_eq!(rendered["totpEnabled"], false);
709        assert_eq!(rendered["enrolmentPending"], false);
710        assert_eq!(rendered["recoveryCodesRemaining"], 0);
711        // The listing shape carries neither, and that is the point.
712        let listed = admin::render_admin_user_json(&user);
713        assert!(listed.get("enrolmentPending").is_none());
714        assert!(listed.get("recoveryCodesRemaining").is_none());
715
716        let enrolled = enrol("alice", db.clone()).await;
717        let rendered = admin::render_admin_user_detail_json(&enrolled, 10);
718        assert_eq!(rendered["totpEnabled"], true);
719        assert_eq!(rendered["recoveryCodesRemaining"], 10);
720        run(show("alice", true), "", db).await.unwrap();
721    }
722
723    /// The window three listings grew when the bare array went. `admin user
724    /// list` is the one listing in the binary that is oldest first, so the first
725    /// page holds the operator created first -- the bootstrap one.
726    #[tokio::test]
727    async fn the_user_listing_pages_oldest_first() {
728        let db = db().await;
729        for name in ["alice", "bob", "carol"] {
730            run(create(name), &format!("{GOOD}\n"), db.clone())
731                .await
732                .unwrap();
733        }
734
735        let (first, total) = users::list_users(2, 0, db.clone()).await.unwrap();
736        let (second, also_total) = users::list_users(2, 2, db.clone()).await.unwrap();
737        assert_eq!((total, also_total), (3, 3), "the total is the table");
738        let walked: Vec<&str> = first
739            .iter()
740            .chain(second.iter())
741            .map(|user| user.username.as_str())
742            .collect();
743        assert_eq!(walked, ["alice", "bob", "carol"]);
744
745        // A nonsense window is clamped rather than refused, `Window::resolve`'s
746        // rule -- `LIMIT -1` is SQLite's "no limit", the one answer a page must
747        // never accidentally give.
748        for (limit, offset) in [(0, 0), (-5, -5)] {
749            run(
750                AdminCommand::User {
751                    command: AdminUserCommand::List {
752                        limit,
753                        offset,
754                        json: true,
755                    },
756                },
757                "",
758                db.clone(),
759            )
760            .await
761            .unwrap();
762        }
763    }
764
765    #[tokio::test]
766    async fn list_renders_in_both_formats_and_is_empty_when_there_are_none() {
767        let db = db().await;
768        for json in [true, false] {
769            run(
770                AdminCommand::User {
771                    command: AdminUserCommand::List {
772                        limit: DEFAULT_LIMIT,
773                        offset: 0,
774                        json,
775                    },
776                },
777                "",
778                db.clone(),
779            )
780            .await
781            .unwrap();
782        }
783
784        run(create("alice"), &format!("{GOOD}\n"), db.clone())
785            .await
786            .unwrap();
787        for json in [true, false] {
788            run(
789                AdminCommand::User {
790                    command: AdminUserCommand::List {
791                        limit: DEFAULT_LIMIT,
792                        offset: 0,
793                        json,
794                    },
795                },
796                "",
797                db.clone(),
798            )
799            .await
800            .unwrap();
801        }
802    }
803
804    #[tokio::test]
805    async fn disable_and_enable_move_the_status_and_refuse_an_unknown_user() {
806        let db = db().await;
807        run(create("alice"), &format!("{GOOD}\n"), db.clone())
808            .await
809            .unwrap();
810
811        let disable = |username: &str| AdminCommand::User {
812            command: AdminUserCommand::Disable {
813                username: username.to_string(),
814            },
815        };
816        let enable = |username: &str| AdminCommand::User {
817            command: AdminUserCommand::Enable {
818                username: username.to_string(),
819            },
820        };
821
822        run(disable("alice"), "", db.clone()).await.unwrap();
823        assert!(
824            !AdminUser::find_by_username("alice", &db)
825                .await
826                .unwrap()
827                .unwrap()
828                .is_active()
829        );
830
831        run(enable("alice"), "", db.clone()).await.unwrap();
832        assert!(
833            AdminUser::find_by_username("alice", &db)
834                .await
835                .unwrap()
836                .unwrap()
837                .is_active()
838        );
839
840        for command in [disable("nobody"), enable("nobody")] {
841            assert_eq!(
842                run(command, "", db.clone()).await.unwrap_err(),
843                CliError("no such admin user: nobody".to_string())
844            );
845        }
846    }
847
848    #[tokio::test]
849    async fn delete_covers_not_found_cancelled_and_deleted() {
850        let db = db().await;
851        let delete = AdminCommand::User {
852            command: AdminUserCommand::Delete {
853                username: "alice".to_string(),
854            },
855        };
856
857        assert_eq!(
858            run(
859                AdminCommand::User {
860                    command: AdminUserCommand::Delete {
861                        username: "nobody".to_string()
862                    }
863                },
864                "",
865                db.clone()
866            )
867            .await
868            .unwrap_err(),
869            CliError("no such admin user: nobody".to_string())
870        );
871
872        run(create("alice"), &format!("{GOOD}\n"), db.clone())
873            .await
874            .unwrap();
875
876        // Declined: `yes` is false here, so the reader's "n" decides.
877        let mut no = b"n\n".as_slice();
878        run_admin_command(
879            AdminCommand::User {
880                command: AdminUserCommand::Delete {
881                    username: "alice".to_string(),
882                },
883            },
884            false,
885            Palette::plain(),
886            &mut no,
887            &Config::default(),
888            db.clone(),
889        )
890        .await
891        .unwrap();
892        assert!(
893            AdminUser::find_by_username("alice", &db)
894                .await
895                .unwrap()
896                .is_some()
897        );
898
899        run(delete, "", db.clone()).await.unwrap();
900        assert!(
901            AdminUser::find_by_username("alice", &db)
902                .await
903                .unwrap()
904                .is_none()
905        );
906    }
907
908    #[tokio::test]
909    async fn session_list_filters_by_user_and_refuses_an_unknown_one() {
910        let db = db().await;
911        run(create("alice"), &format!("{GOOD}\n"), db.clone())
912            .await
913            .unwrap();
914        let alice = AdminUser::find_by_username("alice", &db)
915            .await
916            .unwrap()
917            .unwrap();
918        AdminSession::create(
919            NewSession {
920                user_id: alice.id,
921                token_hash: "hash-a",
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        for (username, json) in [
933            (None, true),
934            (None, false),
935            (Some("alice".to_string()), true),
936            (Some("alice".to_string()), false),
937        ] {
938            run(
939                AdminCommand::Session {
940                    command: AdminSessionCommand::List {
941                        username,
942                        limit: DEFAULT_LIMIT,
943                        offset: 0,
944                        json,
945                    },
946                },
947                "",
948                db.clone(),
949            )
950            .await
951            .unwrap();
952        }
953
954        assert_eq!(
955            run(
956                AdminCommand::Session {
957                    command: AdminSessionCommand::List {
958                        username: Some("nobody".to_string()),
959                        limit: DEFAULT_LIMIT,
960                        offset: 0,
961                        json: false,
962                    },
963                },
964                "",
965                db,
966            )
967            .await
968            .unwrap_err(),
969            CliError("no such admin user: nobody".to_string())
970        );
971    }
972
973    #[tokio::test]
974    async fn session_revoke_handles_user_all_and_neither() {
975        let db = db().await;
976        run(create("alice"), &format!("{GOOD}\n"), db.clone())
977            .await
978            .unwrap();
979        let alice = AdminUser::find_by_username("alice", &db)
980            .await
981            .unwrap()
982            .unwrap();
983        for hash in ["a", "b"] {
984            AdminSession::create(
985                NewSession {
986                    user_id: alice.id,
987                    token_hash: hash,
988                    csrf_token: "csrf",
989                    created_ip: None,
990                    user_agent: None,
991                },
992                std::time::Duration::from_secs(60),
993                &db,
994            )
995            .await
996            .unwrap();
997        }
998
999        // Neither flag: refuse rather than guess which was meant.
1000        assert_eq!(
1001            run(
1002                AdminCommand::Session {
1003                    command: AdminSessionCommand::Revoke {
1004                        user: None,
1005                        all: false
1006                    },
1007                },
1008                "",
1009                db.clone(),
1010            )
1011            .await
1012            .unwrap_err(),
1013            CliError("say whose sessions to revoke: --user <username>, or --all".to_string())
1014        );
1015
1016        assert_eq!(
1017            run(
1018                AdminCommand::Session {
1019                    command: AdminSessionCommand::Revoke {
1020                        user: Some("nobody".to_string()),
1021                        all: false
1022                    },
1023                },
1024                "",
1025                db.clone(),
1026            )
1027            .await
1028            .unwrap_err(),
1029            CliError("no such admin user: nobody".to_string())
1030        );
1031
1032        run(
1033            AdminCommand::Session {
1034                command: AdminSessionCommand::Revoke {
1035                    user: Some("alice".to_string()),
1036                    all: false,
1037                },
1038            },
1039            "",
1040            db.clone(),
1041        )
1042        .await
1043        .unwrap();
1044        assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
1045
1046        // `--all` on an empty table is a no-op, not a failure.
1047        run(
1048            AdminCommand::Session {
1049                command: AdminSessionCommand::Revoke {
1050                    user: None,
1051                    all: true,
1052                },
1053            },
1054            "",
1055            db,
1056        )
1057        .await
1058        .unwrap();
1059    }
1060
1061    // --- Second factor ----------------------------------------------------
1062
1063    fn totp(command: AdminUserTotpCommand) -> AdminCommand {
1064        AdminCommand::User {
1065            command: AdminUserCommand::Totp { command },
1066        }
1067    }
1068
1069    /// Enrols `username` through the operation layer, the way the panel would,
1070    /// so the CLI arms have a real factor to act on.
1071    async fn enrol(username: &str, database: Arc<Database>) -> AdminUser {
1072        let mut user = AdminUser::find_by_username(username, &database)
1073            .await
1074            .unwrap()
1075            .unwrap();
1076        let enrolment =
1077            mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", database.clone())
1078                .await
1079                .unwrap();
1080        let code = admin::totp::totp_at(
1081            &enrolment.secret,
1082            admin::totp::step_at(crate::sqlite::nonce::now_secs()),
1083            admin::totp::DIGITS,
1084        );
1085        mfa::confirm_totp_enrolment(&mut user, &code, None, database)
1086            .await
1087            .unwrap()
1088            .expect("a freshly generated code must confirm its own enrolment");
1089        user
1090    }
1091
1092    #[tokio::test]
1093    async fn totp_status_reports_all_three_states() {
1094        let db = db().await;
1095        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1096            .await
1097            .unwrap();
1098
1099        let status = |json| {
1100            totp(AdminUserTotpCommand::Status {
1101                username: "alice".to_string(),
1102                json,
1103            })
1104        };
1105
1106        // No factor.
1107        run(status(false), "", db.clone()).await.unwrap();
1108        run(status(true), "", db.clone()).await.unwrap();
1109
1110        // Enrolment started and never confirmed: still not a factor, but it
1111        // must not read the same as "off" -- an operator who thinks they
1112        // enrolled has no other way to find out.
1113        let mut user = AdminUser::find_by_username("alice", &db)
1114            .await
1115            .unwrap()
1116            .unwrap();
1117        mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
1118            .await
1119            .unwrap();
1120        let line = render::render_admin_totp_line(&user, 0, Palette::plain());
1121        assert!(line.contains("pending"), "{line}");
1122        run(status(false), "", db.clone()).await.unwrap();
1123
1124        // Confirmed.
1125        let user = enrol("alice", db.clone()).await;
1126        let line = render::render_admin_totp_line(&user, 10, Palette::plain());
1127        assert!(line.contains("totp=enabled"), "{line}");
1128        assert!(line.contains("recovery-codes=10"), "{line}");
1129        run(status(true), "", db).await.unwrap();
1130    }
1131
1132    #[tokio::test]
1133    async fn totp_reset_clears_the_factor_the_codes_and_the_sessions() {
1134        let db = db().await;
1135        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1136            .await
1137            .unwrap();
1138        let user = enrol("alice", db.clone()).await;
1139
1140        AdminSession::create(
1141            NewSession {
1142                user_id: user.id,
1143                token_hash: "live-session",
1144                csrf_token: "csrf",
1145                created_ip: None,
1146                user_agent: None,
1147            },
1148            std::time::Duration::from_secs(3600),
1149            &db,
1150        )
1151        .await
1152        .unwrap();
1153
1154        let reset = || {
1155            totp(AdminUserTotpCommand::Reset {
1156                username: "alice".to_string(),
1157            })
1158        };
1159
1160        // Declined: `yes` is false, so the reader's "n" decides and nothing
1161        // moves. Removing a security control is confirm-gated, unlike
1162        // `order revoke`, which only ever tightens trust.
1163        let mut no = b"n\n".as_slice();
1164        run_admin_command(
1165            reset(),
1166            false,
1167            Palette::plain(),
1168            &mut no,
1169            &Config::default(),
1170            db.clone(),
1171        )
1172        .await
1173        .unwrap();
1174        let unchanged = AdminUser::find_by_username("alice", &db)
1175            .await
1176            .unwrap()
1177            .unwrap();
1178        assert!(unchanged.has_totp());
1179
1180        run(reset(), "", db.clone()).await.unwrap();
1181
1182        let after = AdminUser::find_by_username("alice", &db)
1183            .await
1184            .unwrap()
1185            .unwrap();
1186        assert!(!after.has_totp());
1187        assert!(!after.has_pending_totp());
1188        assert_eq!(
1189            mfa::recovery_codes_remaining(after.id, db.clone())
1190                .await
1191                .unwrap(),
1192            0
1193        );
1194        assert!(
1195            AdminSession::list_all(Some(after.id), &db)
1196                .await
1197                .unwrap()
1198                .is_empty(),
1199            "a factor removed that left a live session behind is a change in name only"
1200        );
1201
1202        // Idempotent, and says so rather than asking again.
1203        run(reset(), "", db).await.unwrap();
1204    }
1205
1206    #[tokio::test]
1207    async fn totp_recovery_codes_supersede_the_previous_set() {
1208        let db = db().await;
1209        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1210            .await
1211            .unwrap();
1212        let user = enrol("alice", db.clone()).await;
1213
1214        let before =
1215            crate::sqlite::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
1216                .await
1217                .unwrap();
1218        assert_eq!(before.len(), 10);
1219
1220        run(
1221            totp(AdminUserTotpCommand::RecoveryCodes {
1222                username: "alice".to_string(),
1223            }),
1224            "",
1225            db.clone(),
1226        )
1227        .await
1228        .unwrap();
1229
1230        let after =
1231            crate::sqlite::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
1232                .await
1233                .unwrap();
1234        assert_eq!(after.len(), 10);
1235        assert!(
1236            after
1237                .iter()
1238                .all(|code| before.iter().all(|old| old.id != code.id)),
1239            "the previous set must stop working"
1240        );
1241    }
1242
1243    /// Recovery codes for an operator with no factor would recover nothing, so
1244    /// the command says so rather than minting ten useless strings.
1245    #[tokio::test]
1246    async fn totp_recovery_codes_refuses_an_operator_with_no_factor() {
1247        let db = db().await;
1248        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1249            .await
1250            .unwrap();
1251
1252        let error = run(
1253            totp(AdminUserTotpCommand::RecoveryCodes {
1254                username: "alice".to_string(),
1255            }),
1256            "",
1257            db,
1258        )
1259        .await
1260        .unwrap_err();
1261        assert!(error.0.contains("no second factor"), "{}", error.0);
1262    }
1263
1264    #[tokio::test]
1265    async fn every_totp_arm_refuses_an_unknown_operator() {
1266        let db = db().await;
1267        let expected = CliError("no such admin user: nobody".to_string());
1268
1269        for command in [
1270            AdminUserTotpCommand::Status {
1271                username: "nobody".to_string(),
1272                json: false,
1273            },
1274            AdminUserTotpCommand::Reset {
1275                username: "nobody".to_string(),
1276            },
1277            AdminUserTotpCommand::RecoveryCodes {
1278                username: "nobody".to_string(),
1279            },
1280        ] {
1281            assert_eq!(
1282                run(totp(command), "", db.clone()).await.unwrap_err(),
1283                expected
1284            );
1285        }
1286    }
1287}