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::cli::CliError;
21use crate::cli::render;
22use crate::cli::window::{DEFAULT_LIMIT, Window};
23use acme_proxy_admin::admin;
24use acme_proxy_admin::admin::changes::{self, OperatorTrail};
25use acme_proxy_admin::admin::mfa;
26use acme_proxy_admin::admin::password::PasswordContext;
27use acme_proxy_admin::admin::prompt::confirm;
28use acme_proxy_admin::admin::users;
29use acme_proxy_admin::admin::users::UserDeleteOutcome;
30use acme_proxy_admin::admin::users::UserError;
31use acme_proxy_core::config::Config;
32use acme_proxy_core::palette::Palette;
33use acme_proxy_jobs::auditor::admin as audit_admin;
34use acme_proxy_jobs::auditor::admin::SessionScope;
35use acme_proxy_store::admin_session::AdminSession;
36use acme_proxy_store::admin_user::AdminRole;
37use acme_proxy_store::admin_user::AdminStatus;
38use acme_proxy_store::admin_user::AdminUser;
39use acme_proxy_store::db::Database;
40
41#[derive(Subcommand)]
42pub enum AdminCommand {
43    /// Manage the operators who can sign in to the web admin.
44    User {
45        #[command(subcommand)]
46        command: AdminUserCommand,
47    },
48    /// Inspect and revoke logged-in browser sessions.
49    Session {
50        #[command(subcommand)]
51        command: AdminSessionCommand,
52    },
53}
54
55#[derive(Subcommand)]
56pub enum AdminUserCommand {
57    /// Create an operator. The password is read from `--password-file`, or
58    /// from stdin.
59    Create {
60        /// The name the operator signs in with.
61        username: String,
62        /// Read the password from this file instead of stdin. A single
63        /// trailing newline is stripped.
64        #[arg(long = "password-file")]
65        password_file: Option<PathBuf>,
66        /// Privilege tier for this operator's web sessions: `admin`
67        /// (everything), `operator` (every CA action but not managing other
68        /// operators), or `viewer` (read-only bar their own account).
69        #[arg(long, default_value = "admin")]
70        role: String,
71        /// Address to send this operator security notifications to (a sign-in
72        /// from an unfamiliar address, a refused second factor, a credential
73        /// change). Stored either way; delivered only while `admin.enabled`
74        /// and `[admin.notify]` are configured.
75        #[arg(long)]
76        contact: Option<String>,
77    },
78    /// List operators, oldest first. Never shows a password hash.
79    List {
80        /// Rows per page. A value below 1 is read as 1.
81        #[arg(long, default_value_t = DEFAULT_LIMIT)]
82        limit: i64,
83        /// Rows to skip before the page starts.
84        #[arg(long, default_value_t = 0)]
85        offset: i64,
86        /// Print the page as JSON: `{items, total, limit, offset}`.
87        #[arg(long)]
88        json: bool,
89    },
90    /// Show one operator, second factor included. Never shows a password hash.
91    Show {
92        /// The operator.
93        username: String,
94        /// Print it as JSON.
95        #[arg(long)]
96        json: bool,
97    },
98    /// Replace an operator's password, revoking every session they hold.
99    Passwd {
100        /// The operator.
101        username: String,
102        /// Read the password from this file instead of stdin. A single
103        /// trailing newline is stripped.
104        #[arg(long = "password-file")]
105        password_file: Option<PathBuf>,
106    },
107    /// Change an operator's privilege tier, revoking every session they hold.
108    Role {
109        /// The operator.
110        username: String,
111        /// The new tier: `admin`, `operator` or `viewer`.
112        role: String,
113    },
114    /// Set or clear the address an operator receives security notifications
115    /// at. Omit `--contact` (or pass an empty value) to clear it.
116    Contact {
117        /// The operator.
118        username: String,
119        /// The new address. Omitted or empty, the address is cleared.
120        #[arg(long)]
121        contact: Option<String>,
122    },
123    /// Delete an operator and every session of theirs.
124    Delete {
125        /// The operator.
126        username: String,
127    },
128    /// Bar an operator from signing in, dropping their current sessions.
129    Disable {
130        /// The operator.
131        username: String,
132    },
133    /// Undo `disable`.
134    Enable {
135        /// The operator.
136        username: String,
137    },
138    /// Inspect or remove an operator's second factor.
139    Totp {
140        #[command(subcommand)]
141        command: AdminUserTotpCommand,
142    },
143}
144
145/// The operator-side half of the second factor.
146///
147/// There is deliberately **no `enrol`** here, and the omission is the same one
148/// that keeps a password out of argv: there is no way to enrol from a terminal
149/// that does not put the base32 secret into scrollback and the shell's own
150/// history. The panel shows it once, behind `Cache-Control: no-store`, on a
151/// loopback listener. What a shell is for is the case the panel cannot serve --
152/// an operator who has lost the factor and so cannot sign in to fix it.
153#[derive(Subcommand)]
154pub enum AdminUserTotpCommand {
155    /// Print whether an operator has a second factor, and how many recovery
156    /// codes are left.
157    Status {
158        /// The operator.
159        username: String,
160        /// Print it as JSON.
161        #[arg(long)]
162        json: bool,
163    },
164    /// Remove an operator's second factor and every recovery code, and revoke
165    /// their sessions.
166    ///
167    /// The lockout lever: a lost phone is a shell command on the host, not a
168    /// database edit. Asks first, because it takes a security control away.
169    Reset {
170        /// The operator.
171        username: String,
172    },
173    /// Mint a fresh set of recovery codes, printed once. The previous set stops
174    /// working immediately.
175    RecoveryCodes {
176        /// The operator.
177        username: String,
178    },
179}
180
181#[derive(Subcommand)]
182pub enum AdminSessionCommand {
183    /// List live sessions, newest first.
184    List {
185        /// Only this operator's sessions.
186        #[arg(long)]
187        user: Option<String>,
188        /// Rows per page. A value below 1 is read as 1.
189        #[arg(long, default_value_t = DEFAULT_LIMIT)]
190        limit: i64,
191        /// Rows to skip before the page starts.
192        #[arg(long, default_value_t = 0)]
193        offset: i64,
194        /// Print the page as JSON: `{items, total, limit, offset}`.
195        #[arg(long)]
196        json: bool,
197    },
198    /// Revoke sessions: one operator's (optionally just one of theirs), or everyone's.
199    Revoke {
200        /// Revoke every session this operator holds (or just one, with `--session`).
201        #[arg(long, conflicts_with = "all")]
202        user: Option<String>,
203        /// Revoke every session on the server.
204        #[arg(long, conflicts_with = "user")]
205        all: bool,
206        /// Revoke a single session of `--user`, by the id shown in `admin session list`.
207        #[arg(long, requires = "user", conflicts_with = "all")]
208        session: Option<String>,
209    },
210}
211
212pub async fn run_admin_command(
213    command: AdminCommand,
214    yes: bool,
215    palette: Palette,
216    reader: &mut impl BufRead,
217    config: &Config,
218    database: Arc<Database>,
219) -> Result<(), CliError> {
220    match command {
221        AdminCommand::User { command } => {
222            run_user_command(command, yes, palette, reader, config, database).await
223        }
224        AdminCommand::Session { command } => run_session_command(command, palette, database).await,
225    }
226}
227
228async fn run_user_command(
229    command: AdminUserCommand,
230    yes: bool,
231    palette: Palette,
232    reader: &mut impl BufRead,
233    config: &Config,
234    database: Arc<Database>,
235) -> Result<(), CliError> {
236    match command {
237        AdminUserCommand::Create {
238            username,
239            password_file,
240            role,
241            contact,
242        } => {
243            let role = super::parse_value::<AdminRole>("--role", &role)?;
244            let password = read_password(password_file.as_deref(), reader)?;
245            let context = PasswordContext::from_config(config, &username);
246            // The tier goes in with the row: one write, so there is no window
247            // in which an operator asked for as a `viewer` exists as an
248            // `admin`.
249            let user =
250                users::create_user(&username, &password, &context, Some(role), database.clone())
251                    .await
252                    .map_err(user_error)?;
253            // Recorded here rather than after the contact step below: that step
254            // validates the address and can fail, and an operator who exists
255            // must appear in the trail whether or not the address stuck.
256            audit_admin::record_cli_action(&database, |actor, client| {
257                audit_admin::operator_created(actor, client, &user.username, role.as_str())
258            })
259            .await;
260            if let Some(contact) = contact.as_deref() {
261                users::set_contact_email(&user.username, Some(contact), database.clone())
262                    .await
263                    .map_err(user_error)?
264                    .ok_or_else(|| not_found(&user.username))?;
265                // Its own row: `operator_created`'s detail names the tier and
266                // says nothing about an address, so a trail reader asking
267                // "when was this address set?" would otherwise find nothing
268                // for an operator created with one.
269                audit_admin::record_cli_action(&database, |actor, client| {
270                    audit_admin::operator_contact_updated(actor, client, &user.username, true)
271                })
272                .await;
273            }
274            // The id, not the password: nothing echoes a credential back.
275            println!(
276                "Created admin user {} ({}), role {role}.",
277                user.username, user.id
278            );
279        }
280        AdminUserCommand::Contact { username, contact } => {
281            let trail = CliTrail::new(config, &database);
282            match changes::change_contact(&username, contact.as_deref(), database.clone(), &trail)
283                .await
284                .map_err(user_error)?
285            {
286                None => return Err(not_found(&username)),
287                Some((user, _changed)) => match user.contact_email {
288                    Some(address) => {
289                        println!("Contact address for {} set to {address}.", user.username)
290                    }
291                    None => println!("Contact address for {} cleared.", user.username),
292                },
293            }
294        }
295        AdminUserCommand::Role { username, role } => {
296            let role: AdminRole = role
297                .parse()
298                .map_err(|error| CliError::bad_request(format!("role: {error}")))?;
299            let trail = CliTrail::new(config, &database);
300            match changes::change_role(&username, role, database.clone(), &trail)
301                .await
302                .map_err(user_error)?
303            {
304                None => return Err(not_found(&username)),
305                Some((user, revoked)) => {
306                    println!(
307                        "Role of {} set to {role}. Every session they held was revoked ({revoked}).",
308                        user.username
309                    );
310                }
311            }
312        }
313        AdminUserCommand::List {
314            limit,
315            offset,
316            json,
317        } => {
318            let window = Window::resolve(limit, offset);
319            let (users, total) = users::list_users(window.limit, window.offset, database).await?;
320            render::print_page(
321                &users,
322                total,
323                window,
324                json,
325                admin::render_admin_user_json,
326                |user| render::render_admin_user_line(user, palette),
327            );
328        }
329        AdminUserCommand::Show { username, json } => {
330            // The same pair `totp status` reads, for the reason
331            // `render_admin_user_detail_json` records: the enrolment state and
332            // the code count are what a listing cannot carry, and they are the
333            // half of an operator's row that decides whether they can sign in.
334            let user = find_user(&username, database.clone()).await?;
335            let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
336
337            if json {
338                println!("{}", admin::render_admin_user_detail_json(&user, remaining));
339            } else {
340                print!(
341                    "{}",
342                    render::render_admin_user_detail_text(&user, remaining, palette)
343                );
344            }
345        }
346        AdminUserCommand::Passwd {
347            username,
348            password_file,
349        } => {
350            let password = read_password(password_file.as_deref(), reader)?;
351            let context = PasswordContext::from_config(config, &username);
352            match users::set_password(&username, &password, &context, database.clone())
353                .await
354                .map_err(user_error)?
355            {
356                None => return Err(not_found(&username)),
357                Some((user, revoked)) => {
358                    audit_admin::record_cli_action(&database, |actor, client| {
359                        audit_admin::operator_password_changed(actor, client, &user.username, false)
360                    })
361                    .await;
362                    notify_credential_change(
363                        config,
364                        &database,
365                        &user,
366                        acme_proxy_jobs::notify::AdminCredentialChange::Password,
367                        None,
368                    )
369                    .await;
370                    // Only when something was revoked, as `disable` and the
371                    // panel do: a row saying "0 sessions" records nothing.
372                    if revoked > 0 {
373                        revoked_sessions_row(
374                            SessionScope::AllOf(user.username.clone()),
375                            revoked,
376                            &database,
377                        )
378                        .await;
379                    }
380                    println!(
381                        "Password changed for {}. Every session they held was revoked ({revoked}).",
382                        user.username
383                    );
384                }
385            }
386        }
387        AdminUserCommand::Delete { username } => {
388            match users::confirm_delete_user(&username, yes, reader, database.clone()).await? {
389                UserDeleteOutcome::NotFound => return Err(not_found(&username)),
390                UserDeleteOutcome::Cancelled => println!("Cancelled."),
391                UserDeleteOutcome::Deleted(_) => {
392                    audit_admin::record_cli_action(&database, |actor, client| {
393                        audit_admin::operator_deleted(actor, client, &username)
394                    })
395                    .await;
396                    println!("Deleted admin user {username}.");
397                }
398            }
399        }
400        AdminUserCommand::Disable { username } => {
401            // Both audit rows -- the status change and the sessions it took --
402            // are written inside `set_status_or_not_found`, so a future caller
403            // cannot get one without the other.
404            set_status_or_not_found(&username, AdminStatus::Disabled, config, database).await?;
405            println!("Disabled {username}. Their sessions were revoked.");
406        }
407        AdminUserCommand::Enable { username } => {
408            set_status_or_not_found(&username, AdminStatus::Active, config, database).await?;
409            println!("Enabled {username}.");
410        }
411        AdminUserCommand::Totp { command } => {
412            run_totp_command(command, yes, palette, reader, config, database).await?;
413        }
414    }
415    Ok(())
416}
417
418async fn run_totp_command(
419    command: AdminUserTotpCommand,
420    yes: bool,
421    palette: Palette,
422    reader: &mut impl BufRead,
423    config: &Config,
424    database: Arc<Database>,
425) -> Result<(), CliError> {
426    match command {
427        AdminUserTotpCommand::Status { username, json } => {
428            let user = find_user(&username, database.clone()).await?;
429            let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
430
431            if json {
432                println!(
433                    "{}",
434                    serde_json::json!({
435                        "username": user.username,
436                        "totpEnabled": user.has_totp(),
437                        "enrolmentPending": user.has_pending_totp(),
438                        "recoveryCodesRemaining": remaining,
439                    })
440                );
441            } else {
442                println!(
443                    "{}",
444                    render::render_admin_totp_line(&user, remaining, palette)
445                );
446            }
447        }
448        AdminUserTotpCommand::Reset { username } => {
449            let mut user = find_user(&username, database.clone()).await?;
450            if !user.has_totp() && !user.has_pending_totp() {
451                println!("{} has no second factor; nothing to reset.", user.username);
452                return Ok(());
453            }
454
455            let prompt = format!(
456                "Remove the second factor and every recovery code for {}, \
457                 and revoke their sessions?",
458                user.username
459            );
460            if !confirm(&prompt, yes, reader) {
461                println!("Cancelled.");
462                return Ok(());
463            }
464
465            // A change made on the operator's behalf, from a shell they are
466            // not signed in from, so there is no session of theirs to keep.
467            let trail = CliTrail::new(config, &database);
468            changes::reset_totp(&mut user, database.clone(), &trail).await?;
469            println!(
470                "Removed the second factor for {}. Their sessions were revoked; \
471                 they can sign in with a password alone until they enrol again.",
472                user.username
473            );
474        }
475        AdminUserTotpCommand::RecoveryCodes { username } => {
476            let user = find_user(&username, database.clone()).await?;
477            if !user.has_totp() {
478                return Err(CliError::bad_request(format!(
479                    "{} has no second factor, so recovery codes would recover nothing: \
480                     enrol from the panel first",
481                    user.username
482                )));
483            }
484
485            let codes = mfa::regenerate_recovery_codes(&user, database.clone()).await?;
486            audit_admin::record_cli_action(&database, |actor, client| {
487                audit_admin::operator_recovery_codes_regenerated(actor, client, &user.username)
488            })
489            .await;
490            notify_credential_change(
491                config,
492                &database,
493                &user,
494                acme_proxy_jobs::notify::AdminCredentialChange::RecoveryCodesRegenerated,
495                None,
496            )
497            .await;
498            // The `eab create` treatment: printed once, stored one-way, and the
499            // previous set is already dead by the time this prints.
500            println!(
501                "New recovery codes for {} — the previous set no longer works.\n\
502                 Store these now; they are not recoverable.\n",
503                user.username
504            );
505            for code in &codes {
506                println!("  {code}");
507            }
508        }
509    }
510    Ok(())
511}
512
513/// Queues `admin_credential_changed` for a change made from the host, through
514/// the `[admin.notify]` dispatcher `serve` would build — delivered by the
515/// running server's worker, since this process has no job runner.
516///
517/// Silent when there is nothing to notify through: the panel is off, so no
518/// dispatcher exists, or `[admin.notify]` does not build, which `serve` would
519/// refuse at startup anyway. A change already made is not undone because its
520/// notification could not be queued.
521///
522/// `by_self` is `false` and there is no address or User-Agent: nobody signed
523/// in made this change, and the message says so.
524async fn notify_credential_change(
525    config: &Config,
526    database: &Arc<Database>,
527    user: &AdminUser,
528    change: acme_proxy_jobs::notify::AdminCredentialChange,
529    previous_recipient: Option<String>,
530) {
531    if !config.admin.enabled {
532        return;
533    }
534    let Ok(egress) = acme_proxy_net::egress::Egress::from_config(config) else {
535        return;
536    };
537    let jobs = acme_proxy_jobs::jobs::JobQueue::new(database.clone(), &config.jobs);
538    let Ok(dispatcher) = acme_proxy_jobs::notify::from_config(
539        acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY,
540        &config.admin.notify,
541        egress.outbound(),
542        &jobs,
543    ) else {
544        return;
545    };
546    dispatcher
547        .dispatch(
548            acme_proxy_jobs::notify::NotifyEvent::AdminCredentialChanged(
549                acme_proxy_jobs::notify::AdminCredentialChangeData::new(
550                    user,
551                    change,
552                    false,
553                    None,
554                    None,
555                    previous_recipient,
556                ),
557            ),
558        )
559        .await;
560}
561
562async fn find_user(username: &str, database: Arc<Database>) -> Result<AdminUser, CliError> {
563    AdminUser::find_by_username(username, &database)
564        .await?
565        .ok_or_else(|| not_found(username))
566}
567
568async fn run_session_command(
569    command: AdminSessionCommand,
570    palette: Palette,
571    database: Arc<Database>,
572) -> Result<(), CliError> {
573    match command {
574        AdminSessionCommand::List {
575            user,
576            limit,
577            offset,
578            json,
579        } => {
580            // Resolved to an id first: `admin_sessions` carries the user id,
581            // and an unknown name must say so rather than quietly listing
582            // every session on the server.
583            let user_id = match user.as_deref() {
584                None => None,
585                Some(name) => match AdminUser::find_by_username(name, &database).await? {
586                    None => return Err(not_found(name)),
587                    Some(user) => Some(user.id),
588                },
589            };
590
591            let window = Window::resolve(limit, offset);
592            let (sessions, total) =
593                AdminSession::search(user_id, window.limit, window.offset, &database).await?;
594            render::print_page(
595                &sessions,
596                total,
597                window,
598                json,
599                admin::render_admin_session_json,
600                |session| render::render_admin_session_line(session, palette),
601            );
602        }
603        AdminSessionCommand::Revoke { user, all, session } => match (user, all, session) {
604            (Some(username), _, Some(fp)) => {
605                let Some(user) = AdminUser::find_by_username(&username, &database).await? else {
606                    return Err(not_found(&username));
607                };
608                match AdminSession::find_by_user_and_fingerprint(user.id, &fp, &database).await? {
609                    None => {
610                        return Err(CliError::bad_request(format!(
611                            "no such session for {username}: {fp}"
612                        )));
613                    }
614                    Some(target) => {
615                        AdminSession::delete(&target.token_hash, &database).await?;
616                        revoked_sessions_row(SessionScope::OneOf(username.clone()), 1, &database)
617                            .await;
618                        println!("Revoked session {fp} for {username}.");
619                    }
620                }
621            }
622            (Some(username), _, None) => {
623                match users::revoke_sessions(&username, database.clone()).await? {
624                    None => return Err(not_found(&username)),
625                    Some(count) => {
626                        revoked_sessions_row(
627                            SessionScope::AllOf(username.clone()),
628                            count,
629                            &database,
630                        )
631                        .await;
632                        println!("Revoked {count} session(s) for {username}.");
633                    }
634                }
635            }
636            (None, true, _) => {
637                let count = AdminSession::delete_all(&database).await?;
638                revoked_sessions_row(SessionScope::Everyone, count, &database).await;
639                println!("Revoked {count} session(s).");
640            }
641            (None, false, _) => {
642                return Err(CliError::bad_request(
643                    "say whose sessions to revoke: --user <username> (optionally --session <id>), \
644                     or --all"
645                        .to_string(),
646                ));
647            }
648        },
649    }
650    Ok(())
651}
652
653/// Records a `session_revoked` audit row for a host-CLI action that ended
654/// sessions -- `admin session revoke`, and the implicit revoke a `passwd`
655/// carries. A `role` or `disable` change writes its own, through
656/// `admin::changes`.
657///
658/// `count` is how many actually went: on the plural scopes the sentence alone
659/// cannot tell forty live cookies from none, which is what an operator reading
660/// the trail after an incident is asking.
661async fn revoked_sessions_row(scope: SessionScope, count: u64, database: &Database) {
662    audit_admin::record_cli_action(database, |actor, client| {
663        audit_admin::session_revoked(actor, client, scope, count)
664    })
665    .await;
666}
667
668async fn set_status_or_not_found(
669    username: &str,
670    status: AdminStatus,
671    config: &Config,
672    database: Arc<Database>,
673) -> Result<(), CliError> {
674    let trail = CliTrail::new(config, &database);
675    match changes::change_status(username, status, database.clone(), &trail).await? {
676        Some(_) => Ok(()),
677        None => Err(not_found(username)),
678    }
679}
680
681/// The CLI's [`OperatorTrail`]: rows attributed to the host CLI, written
682/// straight to the database, and messages queued for the running server's
683/// worker to deliver.
684struct CliTrail<'a> {
685    config: &'a Config,
686    database: &'a Arc<Database>,
687}
688
689impl<'a> CliTrail<'a> {
690    fn new(config: &'a Config, database: &'a Arc<Database>) -> Self {
691        Self { config, database }
692    }
693}
694
695impl OperatorTrail for CliTrail<'_> {
696    async fn record(
697        &self,
698        build: impl FnOnce(
699            acme_proxy_core::audit::Actor,
700            acme_proxy_core::audit::ClientContext,
701        ) -> acme_proxy_core::audit::AuditRecord
702        + Send,
703    ) {
704        audit_admin::record_cli_action(self.database, build).await;
705    }
706
707    async fn notify(
708        &self,
709        user: &AdminUser,
710        change: acme_proxy_jobs::notify::AdminCredentialChange,
711        previous_recipient: Option<String>,
712    ) {
713        notify_credential_change(self.config, self.database, user, change, previous_recipient)
714            .await;
715    }
716}
717
718/// Reads a password from a file, or one line of `reader`.
719///
720/// The file form strips a single trailing newline, so
721/// `printf '%s\n' "$pw" > file` and `printf '%s' "$pw" > file` mean the same
722/// thing — an operator should not have to know which their editor wrote.
723fn read_password(
724    path: Option<&std::path::Path>,
725    reader: &mut impl BufRead,
726) -> Result<String, CliError> {
727    match path {
728        Some(path) => {
729            let raw = std::fs::read_to_string(path).map_err(|error| {
730                CliError::failed(format!("cannot read {}: {error}", path.display()))
731            })?;
732            Ok(raw.strip_suffix('\n').unwrap_or(&raw).to_string())
733        }
734        None => {
735            // No `rpassword`: echo suppression needs a real TTY, which would
736            // break the injectable-reader testability this whole layer is
737            // built on. Warn instead, and point at the flag that avoids it.
738            if std::io::stdin().is_terminal() {
739                eprintln!(
740                    "Note: the password will be echoed. Use --password-file, or pipe it in:\n  \
741                     printf '%s' \"$password\" | acme-proxy admin user create <username>"
742                );
743            }
744            eprintln!("Enter the password, then press Enter:");
745            let mut line = String::new();
746            // EOF with nothing on it is the operator having supplied nothing
747            // usable, which re-running the identical command cannot fix — exit
748            // `3`, the same class as the "say whose sessions to revoke" refusal
749            // above. A *read* failure is the host's, and stays exit `1`; the
750            // two used to be collapsed by an `unwrap_or(0)`.
751            match reader.read_line(&mut line) {
752                Ok(0) => return Err(CliError::bad_request("no password supplied".to_string())),
753                Ok(_) => {}
754                Err(error) => {
755                    return Err(CliError::failed(format!(
756                        "cannot read the password from stdin: {error}"
757                    )));
758                }
759            }
760            // Only the line terminator, never surrounding whitespace: a
761            // password may legitimately begin or end with a space.
762            let password = line.strip_suffix('\n').unwrap_or(&line);
763            let password = password.strip_suffix('\r').unwrap_or(password);
764            Ok(password.to_string())
765        }
766    }
767}
768
769fn user_error(error: UserError) -> CliError {
770    match error {
771        UserError::Database(error) => CliError::from(error),
772        // A duplicate username, a policy rejection or a malformed contact
773        // address is the operator's to fix.
774        UserError::DuplicateUsername(_) | UserError::Policy(_) | UserError::InvalidContact(_) => {
775            CliError::bad_request(error.to_string())
776        }
777    }
778}
779
780fn not_found(username: &str) -> CliError {
781    CliError::bad_request(acme_proxy_admin::admin::subject::Subject::Operator.missing(username))
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use crate::cli::CliErrorKind;
788    use acme_proxy_core::testutil::TempDir;
789    use acme_proxy_store::admin_session::NewSession;
790
791    const GOOD: &str = "a-long-enough-password";
792
793    async fn db() -> Arc<Database> {
794        Arc::new(Database::connect_in_memory().await.unwrap())
795    }
796
797    /// Runs a command with a stdin that supplies `input`, against a default
798    /// configuration.
799    async fn run(
800        command: AdminCommand,
801        input: &str,
802        database: Arc<Database>,
803    ) -> Result<(), CliError> {
804        run_with_config(command, input, &Config::default(), database).await
805    }
806
807    /// [`run`] with the configuration spelled out, for the tests that care
808    /// what [`PasswordContext::from_config`] derived from it.
809    async fn run_with_config(
810        command: AdminCommand,
811        input: &str,
812        config: &Config,
813        database: Arc<Database>,
814    ) -> Result<(), CliError> {
815        let mut reader = input.as_bytes();
816        run_admin_command(
817            command,
818            true,
819            Palette::plain(),
820            &mut reader,
821            config,
822            database,
823        )
824        .await
825    }
826
827    fn create(username: &str) -> AdminCommand {
828        create_with_role(username, "admin")
829    }
830
831    fn create_with_role(username: &str, role: &str) -> AdminCommand {
832        create_full(username, role, None)
833    }
834
835    fn create_full(username: &str, role: &str, contact: Option<&str>) -> AdminCommand {
836        AdminCommand::User {
837            command: AdminUserCommand::Create {
838                username: username.to_string(),
839                password_file: None,
840                role: role.to_string(),
841                contact: contact.map(str::to_string),
842            },
843        }
844    }
845
846    #[tokio::test]
847    async fn create_reads_the_password_from_stdin() {
848        let db = db().await;
849        run(create("alice"), &format!("{GOOD}\n"), db.clone())
850            .await
851            .unwrap();
852
853        let user = AdminUser::find_by_username("alice", &db)
854            .await
855            .unwrap()
856            .unwrap();
857        assert!(user.is_active());
858        assert_eq!(
859            admin::password::verify_password(&user.password_hash, GOOD),
860            Ok(true)
861        );
862    }
863
864    /// Every mutating `admin` subcommand leaves one `cli`-attributed audit row,
865    /// spelled with the object-first name (`operator_disabled`, not
866    /// `admin_operator_disabled`).
867    #[tokio::test]
868    async fn the_operator_lifecycle_writes_cli_audit_rows() {
869        use acme_proxy_store::audit::AuditEntry;
870        use acme_proxy_store::audit::AuditQuery;
871
872        let db = db().await;
873        run(create("alice"), &format!("{GOOD}\n"), db.clone())
874            .await
875            .unwrap();
876        run(
877            AdminCommand::User {
878                command: AdminUserCommand::Disable {
879                    username: "alice".to_string(),
880                },
881            },
882            "",
883            db.clone(),
884        )
885        .await
886        .unwrap();
887        run(
888            AdminCommand::Session {
889                command: AdminSessionCommand::Revoke {
890                    user: None,
891                    all: true,
892                    session: None,
893                },
894            },
895            "",
896            db.clone(),
897        )
898        .await
899        .unwrap();
900
901        let (rows, _) = AuditEntry::search(
902            &AuditQuery {
903                limit: 50,
904                ..AuditQuery::default()
905            },
906            &db,
907        )
908        .await
909        .unwrap();
910        let events: Vec<&str> = rows.iter().map(|r| r.event.as_str()).collect();
911        assert!(events.contains(&"operator_created"), "{events:?}");
912        assert!(events.contains(&"operator_disabled"), "{events:?}");
913        assert!(events.contains(&"session_revoked"), "{events:?}");
914        for row in &rows {
915            assert_eq!(row.actor_kind, "cli");
916            assert_eq!(row.outcome, "success");
917            assert_eq!(
918                row.profile, "",
919                "an operator action is not scoped to a profile"
920            );
921        }
922    }
923
924    /// A role change for an operator holding no session writes the role row
925    /// and nothing else — as `disable` and the panel do. A `session_revoked`
926    /// row counting zero recorded nothing.
927    #[tokio::test]
928    async fn a_role_change_with_no_session_writes_no_session_row() {
929        use acme_proxy_store::audit::AuditEntry;
930        use acme_proxy_store::audit::AuditQuery;
931
932        let db = db().await;
933        for name in ["alice", "root"] {
934            run(create(name), &format!("{GOOD}\n"), db.clone())
935                .await
936                .unwrap();
937        }
938        run(
939            AdminCommand::User {
940                command: AdminUserCommand::Role {
941                    username: "alice".to_string(),
942                    role: "operator".to_string(),
943                },
944            },
945            "",
946            db.clone(),
947        )
948        .await
949        .unwrap();
950
951        let (rows, _) = AuditEntry::search(
952            &AuditQuery {
953                limit: 50,
954                ..AuditQuery::default()
955            },
956            &db,
957        )
958        .await
959        .unwrap();
960        let events: Vec<&str> = rows.iter().map(|r| r.event.as_str()).collect();
961        assert!(events.contains(&"operator_role_changed"), "{events:?}");
962        assert!(!events.contains(&"session_revoked"), "{events:?}");
963    }
964
965    #[tokio::test]
966    async fn create_reads_the_password_from_a_file_and_strips_one_newline() {
967        let dir = TempDir::new("admin-passwd");
968        let path = dir.join("pw");
969        std::fs::write(&path, format!("{GOOD}\n")).unwrap();
970
971        let db = db().await;
972        run(
973            AdminCommand::User {
974                command: AdminUserCommand::Create {
975                    username: "alice".to_string(),
976                    password_file: Some(path),
977                    role: "admin".to_string(),
978                    contact: None,
979                },
980            },
981            "",
982            db.clone(),
983        )
984        .await
985        .unwrap();
986
987        let user = AdminUser::find_by_username("alice", &db)
988            .await
989            .unwrap()
990            .unwrap();
991        assert_eq!(
992            admin::password::verify_password(&user.password_hash, GOOD),
993            Ok(true),
994            "the trailing newline must not be part of the password"
995        );
996    }
997
998    #[tokio::test]
999    async fn create_refuses_a_missing_password_file() {
1000        let db = db().await;
1001        let error = run(
1002            AdminCommand::User {
1003                command: AdminUserCommand::Create {
1004                    username: "alice".to_string(),
1005                    password_file: Some(PathBuf::from("/nonexistent/pw")),
1006                    role: "admin".to_string(),
1007                    contact: None,
1008                },
1009            },
1010            "",
1011            db,
1012        )
1013        .await
1014        .unwrap_err();
1015        assert!(error.message.starts_with("cannot read /nonexistent/pw"));
1016    }
1017
1018    /// The words the *configuration* produced have to reach the terminal, or
1019    /// the operator is told their password is unacceptable and not why. This
1020    /// is also the only test that proves `dispatch`'s `&Config` is threaded
1021    /// all the way to `PasswordContext::from_config` rather than dropped.
1022    #[tokio::test]
1023    async fn create_surfaces_the_context_and_corpus_rules_in_words() {
1024        let db = db().await;
1025
1026        let error = run(create("alice"), "passwordpassword\n", db.clone())
1027            .await
1028            .unwrap_err();
1029        assert!(
1030            error.message.contains("commonly used"),
1031            "got: {}",
1032            error.message
1033        );
1034        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1035
1036        let mut config = Config::default();
1037        config.server.base_url = "https://ca.contoso.example".to_string();
1038        let error = run_with_config(
1039            create("alice"),
1040            "contoso-is-my-password\n",
1041            &config,
1042            db.clone(),
1043        )
1044        .await
1045        .unwrap_err();
1046        assert!(error.message.contains("contoso"), "got: {}", error.message);
1047        assert!(
1048            error.message.contains("names this deployment"),
1049            "got: {}",
1050            error.message
1051        );
1052
1053        // Neither attempt created anything.
1054        assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
1055    }
1056
1057    #[tokio::test]
1058    async fn create_refuses_empty_stdin() {
1059        let db = db().await;
1060        let error = run(create("alice"), "", db).await.unwrap_err();
1061        // Exit `3`: the operator supplied nothing usable, which is not the
1062        // host failing to carry out the request.
1063        assert_eq!(
1064            error,
1065            CliError::bad_request("no password supplied".to_string())
1066        );
1067        assert_eq!(error.exit_code(), 3);
1068    }
1069
1070    fn contact(username: &str, address: Option<&str>) -> AdminCommand {
1071        AdminCommand::User {
1072            command: AdminUserCommand::Contact {
1073                username: username.to_string(),
1074                contact: address.map(str::to_string),
1075            },
1076        }
1077    }
1078
1079    #[tokio::test]
1080    async fn create_with_contact_stores_the_address() {
1081        let db = db().await;
1082        run(
1083            create_full("alice", "admin", Some("alice@example.com")),
1084            &format!("{GOOD}\n"),
1085            db.clone(),
1086        )
1087        .await
1088        .unwrap();
1089        let user = AdminUser::find_by_username("alice", &db)
1090            .await
1091            .unwrap()
1092            .unwrap();
1093        assert_eq!(user.contact_email.as_deref(), Some("alice@example.com"));
1094    }
1095
1096    #[tokio::test]
1097    async fn create_with_a_bad_contact_is_a_bad_request() {
1098        let db = db().await;
1099        let error = run(
1100            create_full("alice", "admin", Some("not an address")),
1101            &format!("{GOOD}\n"),
1102            db.clone(),
1103        )
1104        .await
1105        .unwrap_err();
1106        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1107        // The operator is still created; only the contact was rejected.
1108        assert!(
1109            AdminUser::find_by_username("alice", &db)
1110                .await
1111                .unwrap()
1112                .is_some()
1113        );
1114    }
1115
1116    #[tokio::test]
1117    async fn contact_sets_clears_and_refuses_an_unknown_user() {
1118        let db = db().await;
1119        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1120            .await
1121            .unwrap();
1122
1123        run(contact("alice", Some("a@example.com")), "", db.clone())
1124            .await
1125            .unwrap();
1126        assert_eq!(
1127            AdminUser::find_by_username("alice", &db)
1128                .await
1129                .unwrap()
1130                .unwrap()
1131                .contact_email
1132                .as_deref(),
1133            Some("a@example.com")
1134        );
1135
1136        run(contact("alice", None), "", db.clone()).await.unwrap();
1137        assert_eq!(
1138            AdminUser::find_by_username("alice", &db)
1139                .await
1140                .unwrap()
1141                .unwrap()
1142                .contact_email,
1143            None
1144        );
1145
1146        let error = run(contact("nobody", Some("a@example.com")), "", db.clone())
1147            .await
1148            .unwrap_err();
1149        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1150
1151        let error = run(contact("alice", Some("bad address")), "", db)
1152            .await
1153            .unwrap_err();
1154        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1155    }
1156
1157    #[tokio::test]
1158    async fn create_surfaces_the_policy_and_duplicate_errors_in_words() {
1159        let db = db().await;
1160        let error = run(create("alice"), "short\n", db.clone())
1161            .await
1162            .unwrap_err();
1163        assert!(
1164            error.message.contains("at least 12"),
1165            "got: {}",
1166            error.message
1167        );
1168        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1169
1170        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1171            .await
1172            .unwrap();
1173        let error = run(create("ALICE"), &format!("{GOOD}\n"), db)
1174            .await
1175            .unwrap_err();
1176        assert_eq!(
1177            error,
1178            CliError::bad_request("an admin user named `alice` already exists".to_string())
1179        );
1180    }
1181
1182    /// A credential changed from the host queues `admin_credential_changed`
1183    /// through `[admin.notify]` for the server's worker to deliver — once per
1184    /// change, and not at all while the panel is off, since then no dispatcher
1185    /// exists to deliver it.
1186    #[tokio::test]
1187    async fn a_host_credential_change_queues_its_notification() {
1188        use acme_proxy_store::job::Job;
1189
1190        let db = db().await;
1191        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1192            .await
1193            .unwrap();
1194        run(contact("alice", Some("alice@example.com")), "", db.clone())
1195            .await
1196            .unwrap();
1197        let passwd = || AdminCommand::User {
1198            command: AdminUserCommand::Passwd {
1199                username: "alice".to_string(),
1200                password_file: None,
1201            },
1202        };
1203        let queued = || async {
1204            Job::count_live(acme_proxy_jobs::notify::NOTIFY_JOB_KIND, &db)
1205                .await
1206                .unwrap()
1207        };
1208
1209        // The panel off: nothing to deliver through.
1210        run(passwd(), "another-long-password\n", db.clone())
1211            .await
1212            .unwrap();
1213        assert_eq!(queued().await, 0);
1214
1215        let mut config = Config::default();
1216        config.admin.enabled = true;
1217        config.admin.notify.enabled = vec!["custom".to_string()];
1218        config.admin.notify.custom_enabled = vec!["pager".to_string()];
1219        config.admin.notify.custom.insert(
1220            "pager".to_string(),
1221            acme_proxy_core::config::CustomNotifyConfig {
1222                script_path: "/bin/true".to_string(),
1223                ..acme_proxy_core::config::CustomNotifyConfig::default()
1224            },
1225        );
1226        run_with_config(passwd(), "yet-another-long-password\n", &config, db.clone())
1227            .await
1228            .unwrap();
1229        assert_eq!(queued().await, 1);
1230    }
1231
1232    #[tokio::test]
1233    async fn passwd_changes_the_password_and_reports_an_unknown_user() {
1234        let db = db().await;
1235        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1236            .await
1237            .unwrap();
1238
1239        let passwd = |username: &str| AdminCommand::User {
1240            command: AdminUserCommand::Passwd {
1241                username: username.to_string(),
1242                password_file: None,
1243            },
1244        };
1245
1246        run(passwd("alice"), "another-long-password\n", db.clone())
1247            .await
1248            .unwrap();
1249        let user = AdminUser::find_by_username("alice", &db)
1250            .await
1251            .unwrap()
1252            .unwrap();
1253        assert_eq!(
1254            admin::password::verify_password(&user.password_hash, "another-long-password"),
1255            Ok(true)
1256        );
1257
1258        let error = run(passwd("nobody"), &format!("{GOOD}\n"), db)
1259            .await
1260            .unwrap_err();
1261        assert_eq!(
1262            error,
1263            CliError::bad_request("no such operator: nobody".to_string())
1264        );
1265    }
1266
1267    /// The detail an operator's row could not carry: the enrolment state and
1268    /// the recovery-code count. Both shapes, and an unknown name refused in
1269    /// words rather than answered with an empty object.
1270    #[tokio::test]
1271    async fn show_reports_the_row_and_the_second_factor() {
1272        let db = db().await;
1273        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1274            .await
1275            .unwrap();
1276
1277        let show = |username: &str, json: bool| AdminCommand::User {
1278            command: AdminUserCommand::Show {
1279                username: username.to_string(),
1280                json,
1281            },
1282        };
1283        for json in [true, false] {
1284            run(show("alice", json), "", db.clone()).await.unwrap();
1285        }
1286        assert_eq!(
1287            run(show("nobody", false), "", db.clone())
1288                .await
1289                .unwrap_err(),
1290            CliError::bad_request("no such operator: nobody".to_string())
1291        );
1292
1293        // The three states the detail shape distinguishes, walked in order: no
1294        // factor, enrolment started, confirmed. `totpEnabled` alone cannot tell
1295        // the first two apart, which is why `enrolmentPending` is on this shape
1296        // and not on the listing's.
1297        let user = AdminUser::find_by_username("alice", &db)
1298            .await
1299            .unwrap()
1300            .unwrap();
1301        let rendered = admin::render_admin_user_detail_json(&user, 0);
1302        assert_eq!(rendered["totpEnabled"], false);
1303        assert_eq!(rendered["enrolmentPending"], false);
1304        assert_eq!(rendered["recoveryCodesRemaining"], 0);
1305        // The listing shape carries neither, and that is the point.
1306        let listed = admin::render_admin_user_json(&user);
1307        assert!(listed.get("enrolmentPending").is_none());
1308        assert!(listed.get("recoveryCodesRemaining").is_none());
1309
1310        let enrolled = enrol("alice", db.clone()).await;
1311        let rendered = admin::render_admin_user_detail_json(&enrolled, 10);
1312        assert_eq!(rendered["totpEnabled"], true);
1313        assert_eq!(rendered["recoveryCodesRemaining"], 10);
1314        run(show("alice", true), "", db).await.unwrap();
1315    }
1316
1317    /// The window three listings grew when the bare array went. `admin user
1318    /// list` is the one listing in the binary that is oldest first, so the first
1319    /// page holds the operator created first -- the bootstrap one.
1320    #[tokio::test]
1321    async fn the_user_listing_pages_oldest_first() {
1322        let db = db().await;
1323        for name in ["alice", "bob", "carol"] {
1324            run(create(name), &format!("{GOOD}\n"), db.clone())
1325                .await
1326                .unwrap();
1327        }
1328
1329        let (first, total) = users::list_users(2, 0, db.clone()).await.unwrap();
1330        let (second, also_total) = users::list_users(2, 2, db.clone()).await.unwrap();
1331        assert_eq!((total, also_total), (3, 3), "the total is the table");
1332        let walked: Vec<&str> = first
1333            .iter()
1334            .chain(second.iter())
1335            .map(|user| user.username.as_str())
1336            .collect();
1337        assert_eq!(walked, ["alice", "bob", "carol"]);
1338
1339        // A nonsense window is clamped rather than refused, `Window::resolve`'s
1340        // rule -- `LIMIT -1` is SQLite's "no limit", the one answer a page must
1341        // never accidentally give.
1342        for (limit, offset) in [(0, 0), (-5, -5)] {
1343            run(
1344                AdminCommand::User {
1345                    command: AdminUserCommand::List {
1346                        limit,
1347                        offset,
1348                        json: true,
1349                    },
1350                },
1351                "",
1352                db.clone(),
1353            )
1354            .await
1355            .unwrap();
1356        }
1357    }
1358
1359    #[tokio::test]
1360    async fn list_renders_in_both_formats_and_is_empty_when_there_are_none() {
1361        let db = db().await;
1362        for json in [true, false] {
1363            run(
1364                AdminCommand::User {
1365                    command: AdminUserCommand::List {
1366                        limit: DEFAULT_LIMIT,
1367                        offset: 0,
1368                        json,
1369                    },
1370                },
1371                "",
1372                db.clone(),
1373            )
1374            .await
1375            .unwrap();
1376        }
1377
1378        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1379            .await
1380            .unwrap();
1381        for json in [true, false] {
1382            run(
1383                AdminCommand::User {
1384                    command: AdminUserCommand::List {
1385                        limit: DEFAULT_LIMIT,
1386                        offset: 0,
1387                        json,
1388                    },
1389                },
1390                "",
1391                db.clone(),
1392            )
1393            .await
1394            .unwrap();
1395        }
1396    }
1397
1398    #[tokio::test]
1399    async fn disable_and_enable_move_the_status_and_refuse_an_unknown_user() {
1400        let db = db().await;
1401        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1402            .await
1403            .unwrap();
1404
1405        let disable = |username: &str| AdminCommand::User {
1406            command: AdminUserCommand::Disable {
1407                username: username.to_string(),
1408            },
1409        };
1410        let enable = |username: &str| AdminCommand::User {
1411            command: AdminUserCommand::Enable {
1412                username: username.to_string(),
1413            },
1414        };
1415
1416        run(disable("alice"), "", db.clone()).await.unwrap();
1417        assert!(
1418            !AdminUser::find_by_username("alice", &db)
1419                .await
1420                .unwrap()
1421                .unwrap()
1422                .is_active()
1423        );
1424
1425        run(enable("alice"), "", db.clone()).await.unwrap();
1426        assert!(
1427            AdminUser::find_by_username("alice", &db)
1428                .await
1429                .unwrap()
1430                .unwrap()
1431                .is_active()
1432        );
1433
1434        for command in [disable("nobody"), enable("nobody")] {
1435            assert_eq!(
1436                run(command, "", db.clone()).await.unwrap_err(),
1437                CliError::bad_request("no such operator: nobody".to_string())
1438            );
1439        }
1440    }
1441
1442    #[tokio::test]
1443    async fn create_writes_the_requested_role_and_refuses_an_unknown_one() {
1444        let db = db().await;
1445
1446        run(
1447            create_with_role("reader", "viewer"),
1448            &format!("{GOOD}\n"),
1449            db.clone(),
1450        )
1451        .await
1452        .unwrap();
1453        let user = AdminUser::find_by_username("reader", &db)
1454            .await
1455            .unwrap()
1456            .unwrap();
1457        assert_eq!(user.role(), AdminRole::Viewer);
1458
1459        // The default is the full tier.
1460        run(create("boss"), &format!("{GOOD}\n"), db.clone())
1461            .await
1462            .unwrap();
1463        assert_eq!(
1464            AdminUser::find_by_username("boss", &db)
1465                .await
1466                .unwrap()
1467                .unwrap()
1468                .role(),
1469            AdminRole::Admin
1470        );
1471
1472        let error = run(
1473            create_with_role("nope", "supervisor"),
1474            &format!("{GOOD}\n"),
1475            db.clone(),
1476        )
1477        .await
1478        .unwrap_err();
1479        assert!(error.message.contains("--role"), "{}", error.message);
1480        assert!(error.message.contains("supervisor"), "{}", error.message);
1481        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1482        assert!(
1483            AdminUser::find_by_username("nope", &db)
1484                .await
1485                .unwrap()
1486                .is_none(),
1487            "a refused role must not create a row"
1488        );
1489    }
1490
1491    #[tokio::test]
1492    async fn role_changes_the_tier_revokes_sessions_and_refuses_an_unknown_user() {
1493        use acme_proxy_store::admin_session::AdminSession;
1494        use acme_proxy_store::admin_session::NewSession;
1495
1496        let db = db().await;
1497        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1498            .await
1499            .unwrap();
1500        // A second admin, so demoting alice is not the last-admin refusal --
1501        // `admin::users::demoting_the_last_admin_is_refused` covers that.
1502        run(create("root"), &format!("{GOOD}\n"), db.clone())
1503            .await
1504            .unwrap();
1505        let user = AdminUser::find_by_username("alice", &db)
1506            .await
1507            .unwrap()
1508            .unwrap();
1509        AdminSession::create(
1510            NewSession {
1511                user_id: user.id,
1512                token_hash: "hash",
1513                csrf_token: "csrf",
1514                created_ip: None,
1515                user_agent: None,
1516            },
1517            std::time::Duration::from_secs(60),
1518            &db,
1519        )
1520        .await
1521        .unwrap();
1522
1523        let role = |username: &str, role: &str| AdminCommand::User {
1524            command: AdminUserCommand::Role {
1525                username: username.to_string(),
1526                role: role.to_string(),
1527            },
1528        };
1529
1530        run(role("alice", "operator"), "", db.clone())
1531            .await
1532            .unwrap();
1533        assert_eq!(
1534            AdminUser::find_by_username("alice", &db)
1535                .await
1536                .unwrap()
1537                .unwrap()
1538                .role(),
1539            AdminRole::Operator
1540        );
1541        assert!(
1542            AdminSession::list_all(Some(user.id), &db)
1543                .await
1544                .unwrap()
1545                .is_empty(),
1546            "a role change revokes the operator's sessions"
1547        );
1548
1549        assert_eq!(
1550            run(role("nobody", "viewer"), "", db.clone())
1551                .await
1552                .unwrap_err(),
1553            CliError::bad_request("no such operator: nobody".to_string())
1554        );
1555
1556        let error = run(role("alice", "root"), "", db).await.unwrap_err();
1557        assert!(error.message.contains("role"), "{}", error.message);
1558        assert!(error.message.contains("root"), "{}", error.message);
1559    }
1560
1561    #[tokio::test]
1562    async fn delete_covers_not_found_cancelled_and_deleted() {
1563        let db = db().await;
1564        let delete = AdminCommand::User {
1565            command: AdminUserCommand::Delete {
1566                username: "alice".to_string(),
1567            },
1568        };
1569
1570        assert_eq!(
1571            run(
1572                AdminCommand::User {
1573                    command: AdminUserCommand::Delete {
1574                        username: "nobody".to_string()
1575                    }
1576                },
1577                "",
1578                db.clone()
1579            )
1580            .await
1581            .unwrap_err(),
1582            CliError::bad_request("no such operator: nobody".to_string())
1583        );
1584
1585        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1586            .await
1587            .unwrap();
1588
1589        // Declined: `yes` is false here, so the reader's "n" decides.
1590        let mut no = b"n\n".as_slice();
1591        run_admin_command(
1592            AdminCommand::User {
1593                command: AdminUserCommand::Delete {
1594                    username: "alice".to_string(),
1595                },
1596            },
1597            false,
1598            Palette::plain(),
1599            &mut no,
1600            &Config::default(),
1601            db.clone(),
1602        )
1603        .await
1604        .unwrap();
1605        assert!(
1606            AdminUser::find_by_username("alice", &db)
1607                .await
1608                .unwrap()
1609                .is_some()
1610        );
1611
1612        run(delete, "", db.clone()).await.unwrap();
1613        assert!(
1614            AdminUser::find_by_username("alice", &db)
1615                .await
1616                .unwrap()
1617                .is_none()
1618        );
1619    }
1620
1621    #[tokio::test]
1622    async fn session_list_filters_by_user_and_refuses_an_unknown_one() {
1623        let db = db().await;
1624        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1625            .await
1626            .unwrap();
1627        let alice = AdminUser::find_by_username("alice", &db)
1628            .await
1629            .unwrap()
1630            .unwrap();
1631        AdminSession::create(
1632            NewSession {
1633                user_id: alice.id,
1634                token_hash: "hash-a",
1635                csrf_token: "csrf",
1636                created_ip: None,
1637                user_agent: None,
1638            },
1639            std::time::Duration::from_secs(60),
1640            &db,
1641        )
1642        .await
1643        .unwrap();
1644
1645        for (username, json) in [
1646            (None, true),
1647            (None, false),
1648            (Some("alice".to_string()), true),
1649            (Some("alice".to_string()), false),
1650        ] {
1651            run(
1652                AdminCommand::Session {
1653                    command: AdminSessionCommand::List {
1654                        user: username,
1655                        limit: DEFAULT_LIMIT,
1656                        offset: 0,
1657                        json,
1658                    },
1659                },
1660                "",
1661                db.clone(),
1662            )
1663            .await
1664            .unwrap();
1665        }
1666
1667        assert_eq!(
1668            run(
1669                AdminCommand::Session {
1670                    command: AdminSessionCommand::List {
1671                        user: Some("nobody".to_string()),
1672                        limit: DEFAULT_LIMIT,
1673                        offset: 0,
1674                        json: false,
1675                    },
1676                },
1677                "",
1678                db,
1679            )
1680            .await
1681            .unwrap_err(),
1682            CliError::bad_request("no such operator: nobody".to_string())
1683        );
1684    }
1685
1686    #[tokio::test]
1687    async fn session_revoke_handles_user_all_and_neither() {
1688        let db = db().await;
1689        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1690            .await
1691            .unwrap();
1692        let alice = AdminUser::find_by_username("alice", &db)
1693            .await
1694            .unwrap()
1695            .unwrap();
1696        for hash in ["a", "b"] {
1697            AdminSession::create(
1698                NewSession {
1699                    user_id: alice.id,
1700                    token_hash: hash,
1701                    csrf_token: "csrf",
1702                    created_ip: None,
1703                    user_agent: None,
1704                },
1705                std::time::Duration::from_secs(60),
1706                &db,
1707            )
1708            .await
1709            .unwrap();
1710        }
1711
1712        // Neither flag: refuse rather than guess which was meant.
1713        assert_eq!(
1714            run(
1715                AdminCommand::Session {
1716                    command: AdminSessionCommand::Revoke {
1717                        user: None,
1718                        all: false,
1719                        session: None,
1720                    },
1721                },
1722                "",
1723                db.clone(),
1724            )
1725            .await
1726            .unwrap_err(),
1727            CliError::bad_request(
1728                "say whose sessions to revoke: --user <username> (optionally --session <id>), \
1729                 or --all"
1730                    .to_string()
1731            )
1732        );
1733
1734        assert_eq!(
1735            run(
1736                AdminCommand::Session {
1737                    command: AdminSessionCommand::Revoke {
1738                        user: Some("nobody".to_string()),
1739                        all: false,
1740                        session: None,
1741                    },
1742                },
1743                "",
1744                db.clone(),
1745            )
1746            .await
1747            .unwrap_err(),
1748            CliError::bad_request("no such operator: nobody".to_string())
1749        );
1750
1751        run(
1752            AdminCommand::Session {
1753                command: AdminSessionCommand::Revoke {
1754                    user: Some("alice".to_string()),
1755                    all: false,
1756                    session: None,
1757                },
1758            },
1759            "",
1760            db.clone(),
1761        )
1762        .await
1763        .unwrap();
1764        assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
1765
1766        // `--all` on an empty table is a no-op, not a failure.
1767        run(
1768            AdminCommand::Session {
1769                command: AdminSessionCommand::Revoke {
1770                    user: None,
1771                    all: true,
1772                    session: None,
1773                },
1774            },
1775            "",
1776            db,
1777        )
1778        .await
1779        .unwrap();
1780    }
1781
1782    /// `--user <u> --session <id>` ends exactly one of that operator's sessions,
1783    /// by the fingerprint `admin session list` prints, and leaves the rest.
1784    #[tokio::test]
1785    async fn session_revoke_targets_one_session_by_id() {
1786        let db = db().await;
1787        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1788            .await
1789            .unwrap();
1790        let alice = AdminUser::find_by_username("alice", &db)
1791            .await
1792            .unwrap()
1793            .unwrap();
1794        // Fingerprints are the first 8 characters of the token hash.
1795        for hash in ["11111111aaaa", "22222222bbbb"] {
1796            AdminSession::create(
1797                NewSession {
1798                    user_id: alice.id,
1799                    token_hash: hash,
1800                    csrf_token: "csrf",
1801                    created_ip: None,
1802                    user_agent: None,
1803                },
1804                std::time::Duration::from_secs(60),
1805                &db,
1806            )
1807            .await
1808            .unwrap();
1809        }
1810
1811        let revoke = |user: Option<&str>, session: Option<&str>| {
1812            let db = db.clone();
1813            let command = AdminCommand::Session {
1814                command: AdminSessionCommand::Revoke {
1815                    user: user.map(str::to_string),
1816                    all: false,
1817                    session: session.map(str::to_string),
1818                },
1819            };
1820            async move { run(command, "", db).await }
1821        };
1822
1823        // An unknown fingerprint under a real user says so, and touches nothing.
1824        assert_eq!(
1825            revoke(Some("alice"), Some("deadbeef")).await.unwrap_err(),
1826            CliError::bad_request("no such session for alice: deadbeef".to_string())
1827        );
1828        // An unknown user is refused before the session lookup.
1829        assert_eq!(
1830            revoke(Some("nobody"), Some("11111111")).await.unwrap_err(),
1831            CliError::bad_request("no such operator: nobody".to_string())
1832        );
1833
1834        revoke(Some("alice"), Some("11111111")).await.unwrap();
1835
1836        assert!(
1837            AdminSession::find_by_token_hash("11111111aaaa", &db)
1838                .await
1839                .unwrap()
1840                .is_none(),
1841            "the named session is gone"
1842        );
1843        assert!(
1844            AdminSession::find_by_token_hash("22222222bbbb", &db)
1845                .await
1846                .unwrap()
1847                .is_some(),
1848            "the sibling session survives"
1849        );
1850    }
1851
1852    // --- Second factor ----------------------------------------------------
1853
1854    fn totp(command: AdminUserTotpCommand) -> AdminCommand {
1855        AdminCommand::User {
1856            command: AdminUserCommand::Totp { command },
1857        }
1858    }
1859
1860    /// Enrols `username` through the operation layer, the way the panel would,
1861    /// so the CLI arms have a real factor to act on.
1862    async fn enrol(username: &str, database: Arc<Database>) -> AdminUser {
1863        let mut user = AdminUser::find_by_username(username, &database)
1864            .await
1865            .unwrap()
1866            .unwrap();
1867        let enrolment =
1868            mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", database.clone())
1869                .await
1870                .unwrap();
1871        let code = admin::totp::totp_at(
1872            &enrolment.secret,
1873            admin::totp::step_at(acme_proxy_store::nonce::now_secs()),
1874            admin::totp::DIGITS,
1875        );
1876        mfa::confirm_totp_enrolment(&mut user, &code, None, database)
1877            .await
1878            .unwrap()
1879            .expect("a freshly generated code must confirm its own enrolment");
1880        user
1881    }
1882
1883    #[tokio::test]
1884    async fn totp_status_reports_all_three_states() {
1885        let db = db().await;
1886        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1887            .await
1888            .unwrap();
1889
1890        let status = |json| {
1891            totp(AdminUserTotpCommand::Status {
1892                username: "alice".to_string(),
1893                json,
1894            })
1895        };
1896
1897        // No factor.
1898        run(status(false), "", db.clone()).await.unwrap();
1899        run(status(true), "", db.clone()).await.unwrap();
1900
1901        // Enrolment started and never confirmed: still not a factor, but it
1902        // must not read the same as "off" -- an operator who thinks they
1903        // enrolled has no other way to find out.
1904        let mut user = AdminUser::find_by_username("alice", &db)
1905            .await
1906            .unwrap()
1907            .unwrap();
1908        mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
1909            .await
1910            .unwrap();
1911        let line = render::render_admin_totp_line(&user, 0, Palette::plain());
1912        assert!(line.contains("pending"), "{line}");
1913        run(status(false), "", db.clone()).await.unwrap();
1914
1915        // Confirmed.
1916        let user = enrol("alice", db.clone()).await;
1917        let line = render::render_admin_totp_line(&user, 10, Palette::plain());
1918        assert!(line.contains("totp=enabled"), "{line}");
1919        assert!(line.contains("recovery-codes=10"), "{line}");
1920        run(status(true), "", db).await.unwrap();
1921    }
1922
1923    #[tokio::test]
1924    async fn totp_reset_clears_the_factor_the_codes_and_the_sessions() {
1925        let db = db().await;
1926        run(create("alice"), &format!("{GOOD}\n"), db.clone())
1927            .await
1928            .unwrap();
1929        let user = enrol("alice", db.clone()).await;
1930
1931        AdminSession::create(
1932            NewSession {
1933                user_id: user.id,
1934                token_hash: "live-session",
1935                csrf_token: "csrf",
1936                created_ip: None,
1937                user_agent: None,
1938            },
1939            std::time::Duration::from_secs(3600),
1940            &db,
1941        )
1942        .await
1943        .unwrap();
1944
1945        let reset = || {
1946            totp(AdminUserTotpCommand::Reset {
1947                username: "alice".to_string(),
1948            })
1949        };
1950
1951        // Declined: `yes` is false, so the reader's "n" decides and nothing
1952        // moves. Removing a security control is confirm-gated, unlike
1953        // `order revoke`, which only ever tightens trust.
1954        let mut no = b"n\n".as_slice();
1955        run_admin_command(
1956            reset(),
1957            false,
1958            Palette::plain(),
1959            &mut no,
1960            &Config::default(),
1961            db.clone(),
1962        )
1963        .await
1964        .unwrap();
1965        let unchanged = AdminUser::find_by_username("alice", &db)
1966            .await
1967            .unwrap()
1968            .unwrap();
1969        assert!(unchanged.has_totp());
1970
1971        run(reset(), "", db.clone()).await.unwrap();
1972
1973        let after = AdminUser::find_by_username("alice", &db)
1974            .await
1975            .unwrap()
1976            .unwrap();
1977        assert!(!after.has_totp());
1978        assert!(!after.has_pending_totp());
1979        assert_eq!(
1980            mfa::recovery_codes_remaining(after.id, db.clone())
1981                .await
1982                .unwrap(),
1983            0
1984        );
1985        assert!(
1986            AdminSession::list_all(Some(after.id), &db)
1987                .await
1988                .unwrap()
1989                .is_empty(),
1990            "a factor removed that left a live session behind is a change in name only"
1991        );
1992
1993        // Idempotent, and says so rather than asking again.
1994        run(reset(), "", db).await.unwrap();
1995    }
1996
1997    #[tokio::test]
1998    async fn totp_recovery_codes_supersede_the_previous_set() {
1999        let db = db().await;
2000        run(create("alice"), &format!("{GOOD}\n"), db.clone())
2001            .await
2002            .unwrap();
2003        let user = enrol("alice", db.clone()).await;
2004
2005        let before =
2006            acme_proxy_store::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
2007                .await
2008                .unwrap();
2009        assert_eq!(before.len(), 10);
2010
2011        run(
2012            totp(AdminUserTotpCommand::RecoveryCodes {
2013                username: "alice".to_string(),
2014            }),
2015            "",
2016            db.clone(),
2017        )
2018        .await
2019        .unwrap();
2020
2021        let after =
2022            acme_proxy_store::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
2023                .await
2024                .unwrap();
2025        assert_eq!(after.len(), 10);
2026        assert!(
2027            after
2028                .iter()
2029                .all(|code| before.iter().all(|old| old.id != code.id)),
2030            "the previous set must stop working"
2031        );
2032    }
2033
2034    /// Recovery codes for an operator with no factor would recover nothing, so
2035    /// the command says so rather than minting ten useless strings.
2036    #[tokio::test]
2037    async fn totp_recovery_codes_refuses_an_operator_with_no_factor() {
2038        let db = db().await;
2039        run(create("alice"), &format!("{GOOD}\n"), db.clone())
2040            .await
2041            .unwrap();
2042
2043        let error = run(
2044            totp(AdminUserTotpCommand::RecoveryCodes {
2045                username: "alice".to_string(),
2046            }),
2047            "",
2048            db,
2049        )
2050        .await
2051        .unwrap_err();
2052        assert!(
2053            error.message.contains("no second factor"),
2054            "{}",
2055            error.message
2056        );
2057        assert_eq!(error.kind(), CliErrorKind::BadRequest);
2058    }
2059
2060    #[tokio::test]
2061    async fn every_totp_arm_refuses_an_unknown_operator() {
2062        let db = db().await;
2063        let expected = CliError::bad_request("no such operator: nobody".to_string());
2064
2065        for command in [
2066            AdminUserTotpCommand::Status {
2067                username: "nobody".to_string(),
2068                json: false,
2069            },
2070            AdminUserTotpCommand::Reset {
2071                username: "nobody".to_string(),
2072            },
2073            AdminUserTotpCommand::RecoveryCodes {
2074                username: "nobody".to_string(),
2075            },
2076        ] {
2077            assert_eq!(
2078                run(totp(command), "", db.clone()).await.unwrap_err(),
2079                expected
2080            );
2081        }
2082    }
2083}