use std::io::{BufRead, IsTerminal};
use std::path::PathBuf;
use std::sync::Arc;
use clap::Subcommand;
use crate::cli::CliError;
use crate::cli::render;
use crate::cli::window::{DEFAULT_LIMIT, Window};
use acme_proxy_admin::admin;
use acme_proxy_admin::admin::changes::{self, OperatorTrail};
use acme_proxy_admin::admin::mfa;
use acme_proxy_admin::admin::password::PasswordContext;
use acme_proxy_admin::admin::prompt::confirm;
use acme_proxy_admin::admin::users;
use acme_proxy_admin::admin::users::UserDeleteOutcome;
use acme_proxy_admin::admin::users::UserError;
use acme_proxy_core::config::Config;
use acme_proxy_core::palette::Palette;
use acme_proxy_jobs::auditor::admin as audit_admin;
use acme_proxy_jobs::auditor::admin::SessionScope;
use acme_proxy_store::admin_session::AdminSession;
use acme_proxy_store::admin_user::AdminRole;
use acme_proxy_store::admin_user::AdminStatus;
use acme_proxy_store::admin_user::AdminUser;
use acme_proxy_store::db::Database;
#[derive(Subcommand)]
pub enum AdminCommand {
User {
#[command(subcommand)]
command: AdminUserCommand,
},
Session {
#[command(subcommand)]
command: AdminSessionCommand,
},
}
#[derive(Subcommand)]
pub enum AdminUserCommand {
Create {
username: String,
#[arg(long = "password-file")]
password_file: Option<PathBuf>,
#[arg(long, default_value = "admin")]
role: String,
#[arg(long)]
contact: Option<String>,
},
List {
#[arg(long, default_value_t = DEFAULT_LIMIT)]
limit: i64,
#[arg(long, default_value_t = 0)]
offset: i64,
#[arg(long)]
json: bool,
},
Show {
username: String,
#[arg(long)]
json: bool,
},
Passwd {
username: String,
#[arg(long = "password-file")]
password_file: Option<PathBuf>,
},
Role {
username: String,
role: String,
},
Contact {
username: String,
#[arg(long)]
contact: Option<String>,
},
Delete {
username: String,
},
Disable {
username: String,
},
Enable {
username: String,
},
Totp {
#[command(subcommand)]
command: AdminUserTotpCommand,
},
}
#[derive(Subcommand)]
pub enum AdminUserTotpCommand {
Status {
username: String,
#[arg(long)]
json: bool,
},
Reset {
username: String,
},
RecoveryCodes {
username: String,
},
}
#[derive(Subcommand)]
pub enum AdminSessionCommand {
List {
#[arg(long)]
user: Option<String>,
#[arg(long, default_value_t = DEFAULT_LIMIT)]
limit: i64,
#[arg(long, default_value_t = 0)]
offset: i64,
#[arg(long)]
json: bool,
},
Revoke {
#[arg(long, conflicts_with = "all")]
user: Option<String>,
#[arg(long, conflicts_with = "user")]
all: bool,
#[arg(long, requires = "user", conflicts_with = "all")]
session: Option<String>,
},
}
pub async fn run_admin_command(
command: AdminCommand,
yes: bool,
palette: Palette,
reader: &mut impl BufRead,
config: &Config,
database: Arc<Database>,
) -> Result<(), CliError> {
match command {
AdminCommand::User { command } => {
run_user_command(command, yes, palette, reader, config, database).await
}
AdminCommand::Session { command } => run_session_command(command, palette, database).await,
}
}
async fn run_user_command(
command: AdminUserCommand,
yes: bool,
palette: Palette,
reader: &mut impl BufRead,
config: &Config,
database: Arc<Database>,
) -> Result<(), CliError> {
match command {
AdminUserCommand::Create {
username,
password_file,
role,
contact,
} => {
let role = super::parse_value::<AdminRole>("--role", &role)?;
let password = read_password(password_file.as_deref(), reader)?;
let context = PasswordContext::from_config(config, &username);
let user =
users::create_user(&username, &password, &context, Some(role), database.clone())
.await
.map_err(user_error)?;
audit_admin::record_cli_action(&database, |actor, client| {
audit_admin::operator_created(actor, client, &user.username, role.as_str())
})
.await;
if let Some(contact) = contact.as_deref() {
users::set_contact_email(&user.username, Some(contact), database.clone())
.await
.map_err(user_error)?
.ok_or_else(|| not_found(&user.username))?;
audit_admin::record_cli_action(&database, |actor, client| {
audit_admin::operator_contact_updated(actor, client, &user.username, true)
})
.await;
}
println!(
"Created admin user {} ({}), role {role}.",
user.username, user.id
);
}
AdminUserCommand::Contact { username, contact } => {
let trail = CliTrail::new(config, &database);
match changes::change_contact(&username, contact.as_deref(), database.clone(), &trail)
.await
.map_err(user_error)?
{
None => return Err(not_found(&username)),
Some((user, _changed)) => match user.contact_email {
Some(address) => {
println!("Contact address for {} set to {address}.", user.username)
}
None => println!("Contact address for {} cleared.", user.username),
},
}
}
AdminUserCommand::Role { username, role } => {
let role: AdminRole = role
.parse()
.map_err(|error| CliError::bad_request(format!("role: {error}")))?;
let trail = CliTrail::new(config, &database);
match changes::change_role(&username, role, database.clone(), &trail)
.await
.map_err(user_error)?
{
None => return Err(not_found(&username)),
Some((user, revoked)) => {
println!(
"Role of {} set to {role}. Every session they held was revoked ({revoked}).",
user.username
);
}
}
}
AdminUserCommand::List {
limit,
offset,
json,
} => {
let window = Window::resolve(limit, offset);
let (users, total) = users::list_users(window.limit, window.offset, database).await?;
render::print_page(
&users,
total,
window,
json,
admin::render_admin_user_json,
|user| render::render_admin_user_line(user, palette),
);
}
AdminUserCommand::Show { username, json } => {
let user = find_user(&username, database.clone()).await?;
let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
if json {
println!("{}", admin::render_admin_user_detail_json(&user, remaining));
} else {
print!(
"{}",
render::render_admin_user_detail_text(&user, remaining, palette)
);
}
}
AdminUserCommand::Passwd {
username,
password_file,
} => {
let password = read_password(password_file.as_deref(), reader)?;
let context = PasswordContext::from_config(config, &username);
match users::set_password(&username, &password, &context, database.clone())
.await
.map_err(user_error)?
{
None => return Err(not_found(&username)),
Some((user, revoked)) => {
audit_admin::record_cli_action(&database, |actor, client| {
audit_admin::operator_password_changed(actor, client, &user.username, false)
})
.await;
notify_credential_change(
config,
&database,
&user,
acme_proxy_jobs::notify::AdminCredentialChange::Password,
None,
)
.await;
if revoked > 0 {
revoked_sessions_row(
SessionScope::AllOf(user.username.clone()),
revoked,
&database,
)
.await;
}
println!(
"Password changed for {}. Every session they held was revoked ({revoked}).",
user.username
);
}
}
}
AdminUserCommand::Delete { username } => {
match users::confirm_delete_user(&username, yes, reader, database.clone()).await? {
UserDeleteOutcome::NotFound => return Err(not_found(&username)),
UserDeleteOutcome::Cancelled => println!("Cancelled."),
UserDeleteOutcome::Deleted(_) => {
audit_admin::record_cli_action(&database, |actor, client| {
audit_admin::operator_deleted(actor, client, &username)
})
.await;
println!("Deleted admin user {username}.");
}
}
}
AdminUserCommand::Disable { username } => {
set_status_or_not_found(&username, AdminStatus::Disabled, config, database).await?;
println!("Disabled {username}. Their sessions were revoked.");
}
AdminUserCommand::Enable { username } => {
set_status_or_not_found(&username, AdminStatus::Active, config, database).await?;
println!("Enabled {username}.");
}
AdminUserCommand::Totp { command } => {
run_totp_command(command, yes, palette, reader, config, database).await?;
}
}
Ok(())
}
async fn run_totp_command(
command: AdminUserTotpCommand,
yes: bool,
palette: Palette,
reader: &mut impl BufRead,
config: &Config,
database: Arc<Database>,
) -> Result<(), CliError> {
match command {
AdminUserTotpCommand::Status { username, json } => {
let user = find_user(&username, database.clone()).await?;
let remaining = mfa::recovery_codes_remaining(user.id, database).await?;
if json {
println!(
"{}",
serde_json::json!({
"username": user.username,
"totpEnabled": user.has_totp(),
"enrolmentPending": user.has_pending_totp(),
"recoveryCodesRemaining": remaining,
})
);
} else {
println!(
"{}",
render::render_admin_totp_line(&user, remaining, palette)
);
}
}
AdminUserTotpCommand::Reset { username } => {
let mut user = find_user(&username, database.clone()).await?;
if !user.has_totp() && !user.has_pending_totp() {
println!("{} has no second factor; nothing to reset.", user.username);
return Ok(());
}
let prompt = format!(
"Remove the second factor and every recovery code for {}, \
and revoke their sessions?",
user.username
);
if !confirm(&prompt, yes, reader) {
println!("Cancelled.");
return Ok(());
}
let trail = CliTrail::new(config, &database);
changes::reset_totp(&mut user, database.clone(), &trail).await?;
println!(
"Removed the second factor for {}. Their sessions were revoked; \
they can sign in with a password alone until they enrol again.",
user.username
);
}
AdminUserTotpCommand::RecoveryCodes { username } => {
let user = find_user(&username, database.clone()).await?;
if !user.has_totp() {
return Err(CliError::bad_request(format!(
"{} has no second factor, so recovery codes would recover nothing: \
enrol from the panel first",
user.username
)));
}
let codes = mfa::regenerate_recovery_codes(&user, database.clone()).await?;
audit_admin::record_cli_action(&database, |actor, client| {
audit_admin::operator_recovery_codes_regenerated(actor, client, &user.username)
})
.await;
notify_credential_change(
config,
&database,
&user,
acme_proxy_jobs::notify::AdminCredentialChange::RecoveryCodesRegenerated,
None,
)
.await;
println!(
"New recovery codes for {} — the previous set no longer works.\n\
Store these now; they are not recoverable.\n",
user.username
);
for code in &codes {
println!(" {code}");
}
}
}
Ok(())
}
async fn notify_credential_change(
config: &Config,
database: &Arc<Database>,
user: &AdminUser,
change: acme_proxy_jobs::notify::AdminCredentialChange,
previous_recipient: Option<String>,
) {
if !config.admin.enabled {
return;
}
let Ok(egress) = acme_proxy_net::egress::Egress::from_config(config) else {
return;
};
let jobs = acme_proxy_jobs::jobs::JobQueue::new(database.clone(), &config.jobs);
let Ok(dispatcher) = acme_proxy_jobs::notify::from_config(
acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY,
&config.admin.notify,
egress.outbound(),
&jobs,
) else {
return;
};
dispatcher
.dispatch(
acme_proxy_jobs::notify::NotifyEvent::AdminCredentialChanged(
acme_proxy_jobs::notify::AdminCredentialChangeData::new(
user,
change,
false,
None,
None,
previous_recipient,
),
),
)
.await;
}
async fn find_user(username: &str, database: Arc<Database>) -> Result<AdminUser, CliError> {
AdminUser::find_by_username(username, &database)
.await?
.ok_or_else(|| not_found(username))
}
async fn run_session_command(
command: AdminSessionCommand,
palette: Palette,
database: Arc<Database>,
) -> Result<(), CliError> {
match command {
AdminSessionCommand::List {
user,
limit,
offset,
json,
} => {
let user_id = match user.as_deref() {
None => None,
Some(name) => match AdminUser::find_by_username(name, &database).await? {
None => return Err(not_found(name)),
Some(user) => Some(user.id),
},
};
let window = Window::resolve(limit, offset);
let (sessions, total) =
AdminSession::search(user_id, window.limit, window.offset, &database).await?;
render::print_page(
&sessions,
total,
window,
json,
admin::render_admin_session_json,
|session| render::render_admin_session_line(session, palette),
);
}
AdminSessionCommand::Revoke { user, all, session } => match (user, all, session) {
(Some(username), _, Some(fp)) => {
let Some(user) = AdminUser::find_by_username(&username, &database).await? else {
return Err(not_found(&username));
};
match AdminSession::find_by_user_and_fingerprint(user.id, &fp, &database).await? {
None => {
return Err(CliError::bad_request(format!(
"no such session for {username}: {fp}"
)));
}
Some(target) => {
AdminSession::delete(&target.token_hash, &database).await?;
revoked_sessions_row(SessionScope::OneOf(username.clone()), 1, &database)
.await;
println!("Revoked session {fp} for {username}.");
}
}
}
(Some(username), _, None) => {
match users::revoke_sessions(&username, database.clone()).await? {
None => return Err(not_found(&username)),
Some(count) => {
revoked_sessions_row(
SessionScope::AllOf(username.clone()),
count,
&database,
)
.await;
println!("Revoked {count} session(s) for {username}.");
}
}
}
(None, true, _) => {
let count = AdminSession::delete_all(&database).await?;
revoked_sessions_row(SessionScope::Everyone, count, &database).await;
println!("Revoked {count} session(s).");
}
(None, false, _) => {
return Err(CliError::bad_request(
"say whose sessions to revoke: --user <username> (optionally --session <id>), \
or --all"
.to_string(),
));
}
},
}
Ok(())
}
async fn revoked_sessions_row(scope: SessionScope, count: u64, database: &Database) {
audit_admin::record_cli_action(database, |actor, client| {
audit_admin::session_revoked(actor, client, scope, count)
})
.await;
}
async fn set_status_or_not_found(
username: &str,
status: AdminStatus,
config: &Config,
database: Arc<Database>,
) -> Result<(), CliError> {
let trail = CliTrail::new(config, &database);
match changes::change_status(username, status, database.clone(), &trail).await? {
Some(_) => Ok(()),
None => Err(not_found(username)),
}
}
struct CliTrail<'a> {
config: &'a Config,
database: &'a Arc<Database>,
}
impl<'a> CliTrail<'a> {
fn new(config: &'a Config, database: &'a Arc<Database>) -> Self {
Self { config, database }
}
}
impl OperatorTrail for CliTrail<'_> {
async fn record(
&self,
build: impl FnOnce(
acme_proxy_core::audit::Actor,
acme_proxy_core::audit::ClientContext,
) -> acme_proxy_core::audit::AuditRecord
+ Send,
) {
audit_admin::record_cli_action(self.database, build).await;
}
async fn notify(
&self,
user: &AdminUser,
change: acme_proxy_jobs::notify::AdminCredentialChange,
previous_recipient: Option<String>,
) {
notify_credential_change(self.config, self.database, user, change, previous_recipient)
.await;
}
}
fn read_password(
path: Option<&std::path::Path>,
reader: &mut impl BufRead,
) -> Result<String, CliError> {
match path {
Some(path) => {
let raw = std::fs::read_to_string(path).map_err(|error| {
CliError::failed(format!("cannot read {}: {error}", path.display()))
})?;
Ok(raw.strip_suffix('\n').unwrap_or(&raw).to_string())
}
None => {
if std::io::stdin().is_terminal() {
eprintln!(
"Note: the password will be echoed. Use --password-file, or pipe it in:\n \
printf '%s' \"$password\" | acme-proxy admin user create <username>"
);
}
eprintln!("Enter the password, then press Enter:");
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => return Err(CliError::bad_request("no password supplied".to_string())),
Ok(_) => {}
Err(error) => {
return Err(CliError::failed(format!(
"cannot read the password from stdin: {error}"
)));
}
}
let password = line.strip_suffix('\n').unwrap_or(&line);
let password = password.strip_suffix('\r').unwrap_or(password);
Ok(password.to_string())
}
}
}
fn user_error(error: UserError) -> CliError {
match error {
UserError::Database(error) => CliError::from(error),
UserError::DuplicateUsername(_) | UserError::Policy(_) | UserError::InvalidContact(_) => {
CliError::bad_request(error.to_string())
}
}
}
fn not_found(username: &str) -> CliError {
CliError::bad_request(acme_proxy_admin::admin::subject::Subject::Operator.missing(username))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::CliErrorKind;
use acme_proxy_core::testutil::TempDir;
use acme_proxy_store::admin_session::NewSession;
const GOOD: &str = "a-long-enough-password";
async fn db() -> Arc<Database> {
Arc::new(Database::connect_in_memory().await.unwrap())
}
async fn run(
command: AdminCommand,
input: &str,
database: Arc<Database>,
) -> Result<(), CliError> {
run_with_config(command, input, &Config::default(), database).await
}
async fn run_with_config(
command: AdminCommand,
input: &str,
config: &Config,
database: Arc<Database>,
) -> Result<(), CliError> {
let mut reader = input.as_bytes();
run_admin_command(
command,
true,
Palette::plain(),
&mut reader,
config,
database,
)
.await
}
fn create(username: &str) -> AdminCommand {
create_with_role(username, "admin")
}
fn create_with_role(username: &str, role: &str) -> AdminCommand {
create_full(username, role, None)
}
fn create_full(username: &str, role: &str, contact: Option<&str>) -> AdminCommand {
AdminCommand::User {
command: AdminUserCommand::Create {
username: username.to_string(),
password_file: None,
role: role.to_string(),
contact: contact.map(str::to_string),
},
}
}
#[tokio::test]
async fn create_reads_the_password_from_stdin() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let user = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert!(user.is_active());
assert_eq!(
admin::password::verify_password(&user.password_hash, GOOD),
Ok(true)
);
}
#[tokio::test]
async fn the_operator_lifecycle_writes_cli_audit_rows() {
use acme_proxy_store::audit::AuditEntry;
use acme_proxy_store::audit::AuditQuery;
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
run(
AdminCommand::User {
command: AdminUserCommand::Disable {
username: "alice".to_string(),
},
},
"",
db.clone(),
)
.await
.unwrap();
run(
AdminCommand::Session {
command: AdminSessionCommand::Revoke {
user: None,
all: true,
session: None,
},
},
"",
db.clone(),
)
.await
.unwrap();
let (rows, _) = AuditEntry::search(
&AuditQuery {
limit: 50,
..AuditQuery::default()
},
&db,
)
.await
.unwrap();
let events: Vec<&str> = rows.iter().map(|r| r.event.as_str()).collect();
assert!(events.contains(&"operator_created"), "{events:?}");
assert!(events.contains(&"operator_disabled"), "{events:?}");
assert!(events.contains(&"session_revoked"), "{events:?}");
for row in &rows {
assert_eq!(row.actor_kind, "cli");
assert_eq!(row.outcome, "success");
assert_eq!(
row.profile, "",
"an operator action is not scoped to a profile"
);
}
}
#[tokio::test]
async fn a_role_change_with_no_session_writes_no_session_row() {
use acme_proxy_store::audit::AuditEntry;
use acme_proxy_store::audit::AuditQuery;
let db = db().await;
for name in ["alice", "root"] {
run(create(name), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
}
run(
AdminCommand::User {
command: AdminUserCommand::Role {
username: "alice".to_string(),
role: "operator".to_string(),
},
},
"",
db.clone(),
)
.await
.unwrap();
let (rows, _) = AuditEntry::search(
&AuditQuery {
limit: 50,
..AuditQuery::default()
},
&db,
)
.await
.unwrap();
let events: Vec<&str> = rows.iter().map(|r| r.event.as_str()).collect();
assert!(events.contains(&"operator_role_changed"), "{events:?}");
assert!(!events.contains(&"session_revoked"), "{events:?}");
}
#[tokio::test]
async fn create_reads_the_password_from_a_file_and_strips_one_newline() {
let dir = TempDir::new("admin-passwd");
let path = dir.join("pw");
std::fs::write(&path, format!("{GOOD}\n")).unwrap();
let db = db().await;
run(
AdminCommand::User {
command: AdminUserCommand::Create {
username: "alice".to_string(),
password_file: Some(path),
role: "admin".to_string(),
contact: None,
},
},
"",
db.clone(),
)
.await
.unwrap();
let user = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert_eq!(
admin::password::verify_password(&user.password_hash, GOOD),
Ok(true),
"the trailing newline must not be part of the password"
);
}
#[tokio::test]
async fn create_refuses_a_missing_password_file() {
let db = db().await;
let error = run(
AdminCommand::User {
command: AdminUserCommand::Create {
username: "alice".to_string(),
password_file: Some(PathBuf::from("/nonexistent/pw")),
role: "admin".to_string(),
contact: None,
},
},
"",
db,
)
.await
.unwrap_err();
assert!(error.message.starts_with("cannot read /nonexistent/pw"));
}
#[tokio::test]
async fn create_surfaces_the_context_and_corpus_rules_in_words() {
let db = db().await;
let error = run(create("alice"), "passwordpassword\n", db.clone())
.await
.unwrap_err();
assert!(
error.message.contains("commonly used"),
"got: {}",
error.message
);
assert_eq!(error.kind(), CliErrorKind::BadRequest);
let mut config = Config::default();
config.server.base_url = "https://ca.contoso.example".to_string();
let error = run_with_config(
create("alice"),
"contoso-is-my-password\n",
&config,
db.clone(),
)
.await
.unwrap_err();
assert!(error.message.contains("contoso"), "got: {}", error.message);
assert!(
error.message.contains("names this deployment"),
"got: {}",
error.message
);
assert!(AdminUser::list_all(&db).await.unwrap().is_empty());
}
#[tokio::test]
async fn create_refuses_empty_stdin() {
let db = db().await;
let error = run(create("alice"), "", db).await.unwrap_err();
assert_eq!(
error,
CliError::bad_request("no password supplied".to_string())
);
assert_eq!(error.exit_code(), 3);
}
fn contact(username: &str, address: Option<&str>) -> AdminCommand {
AdminCommand::User {
command: AdminUserCommand::Contact {
username: username.to_string(),
contact: address.map(str::to_string),
},
}
}
#[tokio::test]
async fn create_with_contact_stores_the_address() {
let db = db().await;
run(
create_full("alice", "admin", Some("alice@example.com")),
&format!("{GOOD}\n"),
db.clone(),
)
.await
.unwrap();
let user = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert_eq!(user.contact_email.as_deref(), Some("alice@example.com"));
}
#[tokio::test]
async fn create_with_a_bad_contact_is_a_bad_request() {
let db = db().await;
let error = run(
create_full("alice", "admin", Some("not an address")),
&format!("{GOOD}\n"),
db.clone(),
)
.await
.unwrap_err();
assert_eq!(error.kind(), CliErrorKind::BadRequest);
assert!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.is_some()
);
}
#[tokio::test]
async fn contact_sets_clears_and_refuses_an_unknown_user() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
run(contact("alice", Some("a@example.com")), "", db.clone())
.await
.unwrap();
assert_eq!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap()
.contact_email
.as_deref(),
Some("a@example.com")
);
run(contact("alice", None), "", db.clone()).await.unwrap();
assert_eq!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap()
.contact_email,
None
);
let error = run(contact("nobody", Some("a@example.com")), "", db.clone())
.await
.unwrap_err();
assert_eq!(error.kind(), CliErrorKind::BadRequest);
let error = run(contact("alice", Some("bad address")), "", db)
.await
.unwrap_err();
assert_eq!(error.kind(), CliErrorKind::BadRequest);
}
#[tokio::test]
async fn create_surfaces_the_policy_and_duplicate_errors_in_words() {
let db = db().await;
let error = run(create("alice"), "short\n", db.clone())
.await
.unwrap_err();
assert!(
error.message.contains("at least 12"),
"got: {}",
error.message
);
assert_eq!(error.kind(), CliErrorKind::BadRequest);
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let error = run(create("ALICE"), &format!("{GOOD}\n"), db)
.await
.unwrap_err();
assert_eq!(
error,
CliError::bad_request("an admin user named `alice` already exists".to_string())
);
}
#[tokio::test]
async fn a_host_credential_change_queues_its_notification() {
use acme_proxy_store::job::Job;
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
run(contact("alice", Some("alice@example.com")), "", db.clone())
.await
.unwrap();
let passwd = || AdminCommand::User {
command: AdminUserCommand::Passwd {
username: "alice".to_string(),
password_file: None,
},
};
let queued = || async {
Job::count_live(acme_proxy_jobs::notify::NOTIFY_JOB_KIND, &db)
.await
.unwrap()
};
run(passwd(), "another-long-password\n", db.clone())
.await
.unwrap();
assert_eq!(queued().await, 0);
let mut config = Config::default();
config.admin.enabled = true;
config.admin.notify.enabled = vec!["custom".to_string()];
config.admin.notify.custom_enabled = vec!["pager".to_string()];
config.admin.notify.custom.insert(
"pager".to_string(),
acme_proxy_core::config::CustomNotifyConfig {
script_path: "/bin/true".to_string(),
..acme_proxy_core::config::CustomNotifyConfig::default()
},
);
run_with_config(passwd(), "yet-another-long-password\n", &config, db.clone())
.await
.unwrap();
assert_eq!(queued().await, 1);
}
#[tokio::test]
async fn passwd_changes_the_password_and_reports_an_unknown_user() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let passwd = |username: &str| AdminCommand::User {
command: AdminUserCommand::Passwd {
username: username.to_string(),
password_file: None,
},
};
run(passwd("alice"), "another-long-password\n", db.clone())
.await
.unwrap();
let user = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert_eq!(
admin::password::verify_password(&user.password_hash, "another-long-password"),
Ok(true)
);
let error = run(passwd("nobody"), &format!("{GOOD}\n"), db)
.await
.unwrap_err();
assert_eq!(
error,
CliError::bad_request("no such operator: nobody".to_string())
);
}
#[tokio::test]
async fn show_reports_the_row_and_the_second_factor() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let show = |username: &str, json: bool| AdminCommand::User {
command: AdminUserCommand::Show {
username: username.to_string(),
json,
},
};
for json in [true, false] {
run(show("alice", json), "", db.clone()).await.unwrap();
}
assert_eq!(
run(show("nobody", false), "", db.clone())
.await
.unwrap_err(),
CliError::bad_request("no such operator: nobody".to_string())
);
let user = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
let rendered = admin::render_admin_user_detail_json(&user, 0);
assert_eq!(rendered["totpEnabled"], false);
assert_eq!(rendered["enrolmentPending"], false);
assert_eq!(rendered["recoveryCodesRemaining"], 0);
let listed = admin::render_admin_user_json(&user);
assert!(listed.get("enrolmentPending").is_none());
assert!(listed.get("recoveryCodesRemaining").is_none());
let enrolled = enrol("alice", db.clone()).await;
let rendered = admin::render_admin_user_detail_json(&enrolled, 10);
assert_eq!(rendered["totpEnabled"], true);
assert_eq!(rendered["recoveryCodesRemaining"], 10);
run(show("alice", true), "", db).await.unwrap();
}
#[tokio::test]
async fn the_user_listing_pages_oldest_first() {
let db = db().await;
for name in ["alice", "bob", "carol"] {
run(create(name), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
}
let (first, total) = users::list_users(2, 0, db.clone()).await.unwrap();
let (second, also_total) = users::list_users(2, 2, db.clone()).await.unwrap();
assert_eq!((total, also_total), (3, 3), "the total is the table");
let walked: Vec<&str> = first
.iter()
.chain(second.iter())
.map(|user| user.username.as_str())
.collect();
assert_eq!(walked, ["alice", "bob", "carol"]);
for (limit, offset) in [(0, 0), (-5, -5)] {
run(
AdminCommand::User {
command: AdminUserCommand::List {
limit,
offset,
json: true,
},
},
"",
db.clone(),
)
.await
.unwrap();
}
}
#[tokio::test]
async fn list_renders_in_both_formats_and_is_empty_when_there_are_none() {
let db = db().await;
for json in [true, false] {
run(
AdminCommand::User {
command: AdminUserCommand::List {
limit: DEFAULT_LIMIT,
offset: 0,
json,
},
},
"",
db.clone(),
)
.await
.unwrap();
}
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
for json in [true, false] {
run(
AdminCommand::User {
command: AdminUserCommand::List {
limit: DEFAULT_LIMIT,
offset: 0,
json,
},
},
"",
db.clone(),
)
.await
.unwrap();
}
}
#[tokio::test]
async fn disable_and_enable_move_the_status_and_refuse_an_unknown_user() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let disable = |username: &str| AdminCommand::User {
command: AdminUserCommand::Disable {
username: username.to_string(),
},
};
let enable = |username: &str| AdminCommand::User {
command: AdminUserCommand::Enable {
username: username.to_string(),
},
};
run(disable("alice"), "", db.clone()).await.unwrap();
assert!(
!AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap()
.is_active()
);
run(enable("alice"), "", db.clone()).await.unwrap();
assert!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap()
.is_active()
);
for command in [disable("nobody"), enable("nobody")] {
assert_eq!(
run(command, "", db.clone()).await.unwrap_err(),
CliError::bad_request("no such operator: nobody".to_string())
);
}
}
#[tokio::test]
async fn create_writes_the_requested_role_and_refuses_an_unknown_one() {
let db = db().await;
run(
create_with_role("reader", "viewer"),
&format!("{GOOD}\n"),
db.clone(),
)
.await
.unwrap();
let user = AdminUser::find_by_username("reader", &db)
.await
.unwrap()
.unwrap();
assert_eq!(user.role(), AdminRole::Viewer);
run(create("boss"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
assert_eq!(
AdminUser::find_by_username("boss", &db)
.await
.unwrap()
.unwrap()
.role(),
AdminRole::Admin
);
let error = run(
create_with_role("nope", "supervisor"),
&format!("{GOOD}\n"),
db.clone(),
)
.await
.unwrap_err();
assert!(error.message.contains("--role"), "{}", error.message);
assert!(error.message.contains("supervisor"), "{}", error.message);
assert_eq!(error.kind(), CliErrorKind::BadRequest);
assert!(
AdminUser::find_by_username("nope", &db)
.await
.unwrap()
.is_none(),
"a refused role must not create a row"
);
}
#[tokio::test]
async fn role_changes_the_tier_revokes_sessions_and_refuses_an_unknown_user() {
use acme_proxy_store::admin_session::AdminSession;
use acme_proxy_store::admin_session::NewSession;
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
run(create("root"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let user = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
AdminSession::create(
NewSession {
user_id: user.id,
token_hash: "hash",
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(60),
&db,
)
.await
.unwrap();
let role = |username: &str, role: &str| AdminCommand::User {
command: AdminUserCommand::Role {
username: username.to_string(),
role: role.to_string(),
},
};
run(role("alice", "operator"), "", db.clone())
.await
.unwrap();
assert_eq!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap()
.role(),
AdminRole::Operator
);
assert!(
AdminSession::list_all(Some(user.id), &db)
.await
.unwrap()
.is_empty(),
"a role change revokes the operator's sessions"
);
assert_eq!(
run(role("nobody", "viewer"), "", db.clone())
.await
.unwrap_err(),
CliError::bad_request("no such operator: nobody".to_string())
);
let error = run(role("alice", "root"), "", db).await.unwrap_err();
assert!(error.message.contains("role"), "{}", error.message);
assert!(error.message.contains("root"), "{}", error.message);
}
#[tokio::test]
async fn delete_covers_not_found_cancelled_and_deleted() {
let db = db().await;
let delete = AdminCommand::User {
command: AdminUserCommand::Delete {
username: "alice".to_string(),
},
};
assert_eq!(
run(
AdminCommand::User {
command: AdminUserCommand::Delete {
username: "nobody".to_string()
}
},
"",
db.clone()
)
.await
.unwrap_err(),
CliError::bad_request("no such operator: nobody".to_string())
);
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let mut no = b"n\n".as_slice();
run_admin_command(
AdminCommand::User {
command: AdminUserCommand::Delete {
username: "alice".to_string(),
},
},
false,
Palette::plain(),
&mut no,
&Config::default(),
db.clone(),
)
.await
.unwrap();
assert!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.is_some()
);
run(delete, "", db.clone()).await.unwrap();
assert!(
AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn session_list_filters_by_user_and_refuses_an_unknown_one() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let alice = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
AdminSession::create(
NewSession {
user_id: alice.id,
token_hash: "hash-a",
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(60),
&db,
)
.await
.unwrap();
for (username, json) in [
(None, true),
(None, false),
(Some("alice".to_string()), true),
(Some("alice".to_string()), false),
] {
run(
AdminCommand::Session {
command: AdminSessionCommand::List {
user: username,
limit: DEFAULT_LIMIT,
offset: 0,
json,
},
},
"",
db.clone(),
)
.await
.unwrap();
}
assert_eq!(
run(
AdminCommand::Session {
command: AdminSessionCommand::List {
user: Some("nobody".to_string()),
limit: DEFAULT_LIMIT,
offset: 0,
json: false,
},
},
"",
db,
)
.await
.unwrap_err(),
CliError::bad_request("no such operator: nobody".to_string())
);
}
#[tokio::test]
async fn session_revoke_handles_user_all_and_neither() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let alice = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
for hash in ["a", "b"] {
AdminSession::create(
NewSession {
user_id: alice.id,
token_hash: hash,
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(60),
&db,
)
.await
.unwrap();
}
assert_eq!(
run(
AdminCommand::Session {
command: AdminSessionCommand::Revoke {
user: None,
all: false,
session: None,
},
},
"",
db.clone(),
)
.await
.unwrap_err(),
CliError::bad_request(
"say whose sessions to revoke: --user <username> (optionally --session <id>), \
or --all"
.to_string()
)
);
assert_eq!(
run(
AdminCommand::Session {
command: AdminSessionCommand::Revoke {
user: Some("nobody".to_string()),
all: false,
session: None,
},
},
"",
db.clone(),
)
.await
.unwrap_err(),
CliError::bad_request("no such operator: nobody".to_string())
);
run(
AdminCommand::Session {
command: AdminSessionCommand::Revoke {
user: Some("alice".to_string()),
all: false,
session: None,
},
},
"",
db.clone(),
)
.await
.unwrap();
assert!(AdminSession::list_all(None, &db).await.unwrap().is_empty());
run(
AdminCommand::Session {
command: AdminSessionCommand::Revoke {
user: None,
all: true,
session: None,
},
},
"",
db,
)
.await
.unwrap();
}
#[tokio::test]
async fn session_revoke_targets_one_session_by_id() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let alice = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
for hash in ["11111111aaaa", "22222222bbbb"] {
AdminSession::create(
NewSession {
user_id: alice.id,
token_hash: hash,
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(60),
&db,
)
.await
.unwrap();
}
let revoke = |user: Option<&str>, session: Option<&str>| {
let db = db.clone();
let command = AdminCommand::Session {
command: AdminSessionCommand::Revoke {
user: user.map(str::to_string),
all: false,
session: session.map(str::to_string),
},
};
async move { run(command, "", db).await }
};
assert_eq!(
revoke(Some("alice"), Some("deadbeef")).await.unwrap_err(),
CliError::bad_request("no such session for alice: deadbeef".to_string())
);
assert_eq!(
revoke(Some("nobody"), Some("11111111")).await.unwrap_err(),
CliError::bad_request("no such operator: nobody".to_string())
);
revoke(Some("alice"), Some("11111111")).await.unwrap();
assert!(
AdminSession::find_by_token_hash("11111111aaaa", &db)
.await
.unwrap()
.is_none(),
"the named session is gone"
);
assert!(
AdminSession::find_by_token_hash("22222222bbbb", &db)
.await
.unwrap()
.is_some(),
"the sibling session survives"
);
}
fn totp(command: AdminUserTotpCommand) -> AdminCommand {
AdminCommand::User {
command: AdminUserCommand::Totp { command },
}
}
async fn enrol(username: &str, database: Arc<Database>) -> AdminUser {
let mut user = AdminUser::find_by_username(username, &database)
.await
.unwrap()
.unwrap();
let enrolment =
mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", database.clone())
.await
.unwrap();
let code = admin::totp::totp_at(
&enrolment.secret,
admin::totp::step_at(acme_proxy_store::nonce::now_secs()),
admin::totp::DIGITS,
);
mfa::confirm_totp_enrolment(&mut user, &code, None, database)
.await
.unwrap()
.expect("a freshly generated code must confirm its own enrolment");
user
}
#[tokio::test]
async fn totp_status_reports_all_three_states() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let status = |json| {
totp(AdminUserTotpCommand::Status {
username: "alice".to_string(),
json,
})
};
run(status(false), "", db.clone()).await.unwrap();
run(status(true), "", db.clone()).await.unwrap();
let mut user = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
mfa::begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
.await
.unwrap();
let line = render::render_admin_totp_line(&user, 0, Palette::plain());
assert!(line.contains("pending"), "{line}");
run(status(false), "", db.clone()).await.unwrap();
let user = enrol("alice", db.clone()).await;
let line = render::render_admin_totp_line(&user, 10, Palette::plain());
assert!(line.contains("totp=enabled"), "{line}");
assert!(line.contains("recovery-codes=10"), "{line}");
run(status(true), "", db).await.unwrap();
}
#[tokio::test]
async fn totp_reset_clears_the_factor_the_codes_and_the_sessions() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let user = enrol("alice", db.clone()).await;
AdminSession::create(
NewSession {
user_id: user.id,
token_hash: "live-session",
csrf_token: "csrf",
created_ip: None,
user_agent: None,
},
std::time::Duration::from_secs(3600),
&db,
)
.await
.unwrap();
let reset = || {
totp(AdminUserTotpCommand::Reset {
username: "alice".to_string(),
})
};
let mut no = b"n\n".as_slice();
run_admin_command(
reset(),
false,
Palette::plain(),
&mut no,
&Config::default(),
db.clone(),
)
.await
.unwrap();
let unchanged = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert!(unchanged.has_totp());
run(reset(), "", db.clone()).await.unwrap();
let after = AdminUser::find_by_username("alice", &db)
.await
.unwrap()
.unwrap();
assert!(!after.has_totp());
assert!(!after.has_pending_totp());
assert_eq!(
mfa::recovery_codes_remaining(after.id, db.clone())
.await
.unwrap(),
0
);
assert!(
AdminSession::list_all(Some(after.id), &db)
.await
.unwrap()
.is_empty(),
"a factor removed that left a live session behind is a change in name only"
);
run(reset(), "", db).await.unwrap();
}
#[tokio::test]
async fn totp_recovery_codes_supersede_the_previous_set() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let user = enrol("alice", db.clone()).await;
let before =
acme_proxy_store::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
.await
.unwrap();
assert_eq!(before.len(), 10);
run(
totp(AdminUserTotpCommand::RecoveryCodes {
username: "alice".to_string(),
}),
"",
db.clone(),
)
.await
.unwrap();
let after =
acme_proxy_store::admin_recovery_code::AdminRecoveryCode::list_unused(user.id, &db)
.await
.unwrap();
assert_eq!(after.len(), 10);
assert!(
after
.iter()
.all(|code| before.iter().all(|old| old.id != code.id)),
"the previous set must stop working"
);
}
#[tokio::test]
async fn totp_recovery_codes_refuses_an_operator_with_no_factor() {
let db = db().await;
run(create("alice"), &format!("{GOOD}\n"), db.clone())
.await
.unwrap();
let error = run(
totp(AdminUserTotpCommand::RecoveryCodes {
username: "alice".to_string(),
}),
"",
db,
)
.await
.unwrap_err();
assert!(
error.message.contains("no second factor"),
"{}",
error.message
);
assert_eq!(error.kind(), CliErrorKind::BadRequest);
}
#[tokio::test]
async fn every_totp_arm_refuses_an_unknown_operator() {
let db = db().await;
let expected = CliError::bad_request("no such operator: nobody".to_string());
for command in [
AdminUserTotpCommand::Status {
username: "nobody".to_string(),
json: false,
},
AdminUserTotpCommand::Reset {
username: "nobody".to_string(),
},
AdminUserTotpCommand::RecoveryCodes {
username: "nobody".to_string(),
},
] {
assert_eq!(
run(totp(command), "", db.clone()).await.unwrap_err(),
expected
);
}
}
}