use clap::{Subcommand, ValueEnum};
use sqlx::Row as _;
use rustio_admin::admin::audit::{record, ActionType, AuditEvent, LogEntry};
use rustio_admin::auth::emergency::{
self as fw_emergency, DisableMfaOutcome, EmergencyAccessOutcome, PromoteOutcome, ResetOutcome,
UnlockOutcome,
};
use rustio_admin::auth::{DefaultPasswordPolicy, PasswordPolicy};
use rustio_admin::{auth, Db, Role};
use crate::emergency_ui::{self, ConfirmOutcome, OperationContext};
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum CliRole {
User,
Staff,
Supervisor,
Administrator,
Developer,
}
impl From<CliRole> for Role {
fn from(r: CliRole) -> Self {
match r {
CliRole::User => Role::User,
CliRole::Staff => Role::Staff,
CliRole::Supervisor => Role::Supervisor,
CliRole::Administrator => Role::Administrator,
CliRole::Developer => Role::Developer,
}
}
}
#[derive(Subcommand)]
pub enum Action {
Create {
#[arg(long)]
email: String,
#[arg(long, value_enum, default_value_t = CliRole::User)]
role: CliRole,
#[arg(long)]
password: Option<String>,
#[arg(long)]
first_name: Option<String>,
#[arg(long)]
last_name: Option<String>,
#[arg(long)]
display_name: Option<String>,
#[arg(long)]
job_title: Option<String>,
},
List,
Role {
#[arg(long)]
email: String,
#[arg(value_enum)]
role: CliRole,
},
Delete {
#[arg(long)]
email: String,
},
ResetPassword {
#[arg(long)]
email: String,
#[arg(long)]
reason: String,
#[arg(long)]
temp_password: Option<String>,
#[arg(long)]
yes: bool,
},
Unlock {
#[arg(long)]
email: String,
#[arg(long)]
reason: String,
#[arg(long)]
yes: bool,
},
DisableMfa {
#[arg(long)]
email: String,
#[arg(long)]
reason: String,
#[arg(long)]
yes: bool,
},
Promote {
#[arg(long)]
email: String,
#[arg(long = "to-role", value_enum)]
to_role: CliRole,
#[arg(long)]
reason: String,
#[arg(long)]
yes: bool,
},
EmergencyAccess {
#[arg(long)]
email: String,
#[arg(long)]
reason: String,
#[arg(long = "ttl-minutes", default_value_t = 15)]
ttl_minutes: i64,
#[arg(long)]
yes: bool,
},
}
pub async fn run(action: Action) -> Result<(), String> {
let db = crate::db().await?;
match action {
Action::Create {
email,
role,
password,
first_name,
last_name,
display_name,
job_title,
} => {
create(
db,
email,
role.into(),
password,
first_name,
last_name,
display_name,
job_title,
)
.await
}
Action::List => list(db).await,
Action::Role { email, role } => set_role(db, email, role.into()).await,
Action::Delete { email } => delete(db, email).await,
Action::ResetPassword {
email,
reason,
temp_password,
yes,
} => reset_password(db, email, reason, temp_password, yes).await,
Action::Unlock { email, reason, yes } => unlock(db, email, reason, yes).await,
Action::DisableMfa { email, reason, yes } => disable_mfa(db, email, reason, yes).await,
Action::Promote {
email,
to_role,
reason,
yes,
} => promote(db, email, to_role.into(), reason, yes).await,
Action::EmergencyAccess {
email,
reason,
ttl_minutes,
yes,
} => emergency_access(db, email, reason, ttl_minutes, yes).await,
}
}
#[allow(clippy::too_many_arguments)]
async fn create(
db: Db,
email: String,
role: Role,
password: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
display_name: Option<String>,
job_title: Option<String>,
) -> Result<(), String> {
auth::init_tables(&db)
.await
.map_err(|e| format!("init auth tables: {e}"))?;
if auth::find_user_by_email(&db, &email)
.await
.map_err(|e| format!("lookup: {e}"))?
.is_some()
{
return Err(format!("a user with email {email} already exists"));
}
let pw = match password {
Some(p) => p,
None => prompt_new_password()?,
};
let policy = DefaultPasswordPolicy::new();
policy.validate(&pw).map_err(|e| e.to_string())?;
let id = auth::create_user(&db, &email, &pw, role)
.await
.map_err(|e| format!("create_user: {e}"))?;
if first_name.is_some()
|| last_name.is_some()
|| display_name.is_some()
|| job_title.is_some()
{
sqlx::query(
"UPDATE rustio_users SET \
first_name = COALESCE($1, first_name), \
last_name = COALESCE($2, last_name), \
display_name = COALESCE($3, display_name), \
job_title = COALESCE($4, job_title) \
WHERE id = $5",
)
.bind(first_name.as_deref())
.bind(last_name.as_deref())
.bind(display_name.as_deref())
.bind(job_title.as_deref())
.bind(id)
.execute(db.pool())
.await
.map_err(|e| format!("profile fields: {e}"))?;
}
println!("Created user id={id} email={email} role={role}");
Ok(())
}
async fn list(db: Db) -> Result<(), String> {
let rows = sqlx::query(
"SELECT id, email, role, is_active, created_at
FROM rustio_users
ORDER BY id ASC",
)
.fetch_all(db.pool())
.await
.map_err(|e| format!("query: {e}"))?;
if rows.is_empty() {
println!("No users.");
return Ok(());
}
println!(
"{:>4} {:<32} {:<14} {:<6} CREATED",
"ID", "EMAIL", "ROLE", "ACTIVE"
);
for r in rows {
let id: i64 = r.try_get("id").unwrap_or(0);
let email: String = r.try_get("email").unwrap_or_default();
let role: String = r.try_get("role").unwrap_or_default();
let active: bool = r.try_get("is_active").unwrap_or(false);
let created: chrono::DateTime<chrono::Utc> = r
.try_get("created_at")
.unwrap_or_else(|_| chrono::Utc::now());
println!(
"{:>4} {:<32} {:<14} {:<6} {}",
id,
email,
role,
if active { "yes" } else { "no" },
created.format("%Y-%m-%d %H:%M UTC")
);
}
Ok(())
}
async fn set_role(db: Db, email: String, role: Role) -> Result<(), String> {
let user = auth::find_user_by_email(&db, &email)
.await
.map_err(|e| format!("lookup: {e}"))?
.ok_or_else(|| format!("no user with email {email}"))?;
if let Some(orphaned) = auth::would_orphan_protected(&db, user.id, role, true)
.await
.map_err(|e| format!("orphan check: {e}"))?
{
return Err(format!(
"Refusing — this change would leave the system with zero active {}s.",
orphaned.label()
));
}
auth::update_user_role(&db, user.id, role)
.await
.map_err(|e| format!("update_user_role: {e}"))?;
println!("Set role of {email} to {role}");
Ok(())
}
async fn delete(db: Db, email: String) -> Result<(), String> {
let user = auth::find_user_by_email(&db, &email)
.await
.map_err(|e| format!("lookup: {e}"))?
.ok_or_else(|| format!("no user with email {email}"))?;
if let Some(orphaned) = auth::would_orphan_protected(&db, user.id, Role::User, false)
.await
.map_err(|e| format!("orphan check: {e}"))?
{
return Err(format!(
"Refusing — deleting this user would leave zero active {}s.",
orphaned.label()
));
}
sqlx::query("DELETE FROM rustio_users WHERE id = $1")
.bind(user.id)
.execute(db.pool())
.await
.map_err(|e| format!("delete: {e}"))?;
println!("Deleted user id={} email={email}", user.id);
Ok(())
}
fn prompt_new_password() -> Result<String, String> {
let pw1 =
rpassword::prompt_password("Password: ").map_err(|e| format!("read password: {e}"))?;
let pw2 = rpassword::prompt_password("Confirm password: ")
.map_err(|e| format!("read password: {e}"))?;
if pw1 != pw2 {
return Err("Passwords don't match.".into());
}
Ok(pw1)
}
const DEFAULT_TEMP_PASSWORD_LEN: usize = 20;
async fn preflight(
db: &Db,
operation: &'static str,
email: &str,
reason_arg: &str,
yes: bool,
) -> Result<OperationContext, String> {
let reason = emergency_ui::validate_reason(reason_arg)?;
let target = auth::find_user_by_email(db, email)
.await
.map_err(|e| format!("lookup target: {e}"))?
.ok_or_else(|| format!("no user with email {email}"))?;
let ctx = OperationContext {
operation,
target_email: target.email.clone(),
target_user_id: target.id,
target_role: target.role.to_string(),
reason,
os_actor: emergency_ui::os_actor(),
when: emergency_ui::now(),
};
emergency_ui::print_banner(&ctx);
match emergency_ui::require_confirm(yes) {
ConfirmOutcome::Confirmed => Ok(ctx),
ConfirmOutcome::Aborted => {
println!("Aborted.");
Err("user did not confirm".into())
}
ConfirmOutcome::NeedsTtyOrYesFlag => {
Err("Refusing to run without a TTY (or pass --yes for scripting)".to_string())
}
}
}
async fn write_emergency_audit(
db: &Db,
ctx: &OperationContext,
cli_op: &str,
per_op_metadata: serde_json::Value,
) -> Result<String, String> {
let correlation_id = fw_emergency::fresh_correlation_id();
let argv: Vec<String> = std::env::args().collect();
let mut metadata = serde_json::Map::new();
metadata.insert(
"cli_operation".into(),
serde_json::Value::String(cli_op.into()),
);
metadata.insert(
"reason".into(),
serde_json::Value::String(ctx.reason.clone()),
);
metadata.insert(
"os_actor".into(),
serde_json::Value::String(ctx.os_actor.clone()),
);
metadata.insert(
"cli_invocation".into(),
serde_json::Value::String(emergency_ui::redact_reason_in_argv(&argv)),
);
if let serde_json::Value::Object(extra) = per_op_metadata {
for (k, v) in extra {
metadata.insert(k, v);
}
}
let summary = build_summary(ctx.operation, &ctx.reason);
let entry = LogEntry {
user_id: ctx.target_user_id,
action_type: ActionType::Update,
model_name: "users",
object_id: ctx.target_user_id,
ip_address: None,
summary,
correlation_id: Some(&correlation_id),
session_id: None,
metadata: Some(serde_json::Value::Object(metadata)),
actor_user_id: None,
event: Some(AuditEvent::EmergencyRecovery),
};
record(db, entry)
.await
.map_err(|e| format!("audit record: {e}"))?;
Ok(correlation_id)
}
async fn reset_password(
db: Db,
email: String,
reason_arg: String,
temp_password: Option<String>,
yes: bool,
) -> Result<(), String> {
let ctx = preflight(&db, "reset-password", &email, &reason_arg, yes).await?;
let temp_password = match temp_password {
Some(p) => {
let policy = DefaultPasswordPolicy::new();
policy
.validate(&p)
.map_err(|e| format!("--temp-password rejected by policy: {e}"))?;
p
}
None => fw_emergency::generate_temp_password(DEFAULT_TEMP_PASSWORD_LEN),
};
let outcome = fw_emergency::reset_password(&db, ctx.target_user_id, &temp_password)
.await
.map_err(|e| format!("reset_password: {e}"))?;
let revoked = match outcome {
ResetOutcome::Ok {
revoked_session_count,
} => revoked_session_count,
ResetOutcome::UnknownTarget => {
return Err(format!(
"User vanished between lookup and reset; no rows changed (email={email})"
));
}
};
let correlation_id = write_emergency_audit(
&db,
&ctx,
"reset_password",
serde_json::json!({
"revoked_session_count": revoked,
"must_change_password_set": true,
}),
)
.await?;
println!();
println!(
"✓ Password reset for {email} (user_id={})",
ctx.target_user_id
);
println!(" Sessions revoked: {revoked}");
println!(" must_change_password set; user must rotate on next login.");
println!();
println!(" Temporary password (shown once — record now):");
println!();
println!(" {temp_password}");
println!();
println!(" Audit correlation: {correlation_id}");
Ok(())
}
async fn unlock(db: Db, email: String, reason_arg: String, yes: bool) -> Result<(), String> {
let ctx = preflight(&db, "unlock", &email, &reason_arg, yes).await?;
let outcome = fw_emergency::unlock(&db, ctx.target_user_id)
.await
.map_err(|e| format!("unlock: {e}"))?;
let previously_locked = match outcome {
UnlockOutcome::Ok { previously_locked } => previously_locked,
UnlockOutcome::UnknownTarget => {
return Err(format!(
"User vanished between lookup and unlock; no rows changed (email={email})"
));
}
};
let correlation_id = write_emergency_audit(
&db,
&ctx,
"unlock",
serde_json::json!({
"previously_locked": previously_locked,
}),
)
.await?;
println!();
println!(
"✓ Unlock applied to {email} (user_id={})",
ctx.target_user_id
);
if previously_locked {
println!(" Account was actively locked; locked_until + failed_login_count cleared.");
} else {
println!(" Note: account was not locked at run time; no functional change.");
println!(" (The audit row still landed — the action remains forensically visible.)");
}
println!(" Audit correlation: {correlation_id}");
Ok(())
}
async fn disable_mfa(db: Db, email: String, reason_arg: String, yes: bool) -> Result<(), String> {
let ctx = preflight(&db, "disable-mfa", &email, &reason_arg, yes).await?;
let outcome = fw_emergency::disable_mfa(&db, ctx.target_user_id)
.await
.map_err(|e| format!("disable_mfa: {e}"))?;
let (was_enabled, deleted_backup_codes, revoked) = match outcome {
DisableMfaOutcome::Ok {
was_enabled,
deleted_backup_codes,
revoked_session_count,
} => (was_enabled, deleted_backup_codes, revoked_session_count),
DisableMfaOutcome::UnknownTarget => {
return Err(format!(
"User vanished between lookup and disable_mfa; no rows changed (email={email})"
));
}
};
let correlation_id = write_emergency_audit(
&db,
&ctx,
"disable_mfa",
serde_json::json!({
"was_enabled": was_enabled,
"deleted_backup_codes": deleted_backup_codes,
"revoked_session_count": revoked,
}),
)
.await?;
println!();
println!("✓ MFA disabled on {email} (user_id={})", ctx.target_user_id);
if was_enabled {
println!(
" MFA secret cleared. {deleted_backup_codes} backup code(s) deleted. {revoked} session(s) revoked."
);
} else {
println!(" Note: MFA was not enabled at run time; no functional change.");
println!(" (The audit row still landed — the action remains forensically visible.)");
}
println!();
println!(" If the deployment's MfaPolicy is `Required` (or `RequiredForRoles`),");
println!(" the user will be redirected to MFA enrolment on their next login.");
println!(" Audit correlation: {correlation_id}");
Ok(())
}
async fn promote(
db: Db,
email: String,
new_role: Role,
reason_arg: String,
yes: bool,
) -> Result<(), String> {
let ctx = preflight(&db, "promote", &email, &reason_arg, yes).await?;
let outcome = fw_emergency::promote(&db, ctx.target_user_id, new_role)
.await
.map_err(|e| format!("promote: {e}"))?;
match outcome {
PromoteOutcome::UnknownTarget => Err(format!(
"User vanished between lookup and promote; no rows changed (email={email})"
)),
PromoteOutcome::SoleAdministratorDemoteRefused => {
Err(format!(
"Refused: {email} is the sole active administrator; demoting them would leave \
the deployment with zero administrators. Promote another user to administrator \
first, then re-run."
))
}
PromoteOutcome::NoChange { current_role } => {
let correlation_id = write_emergency_audit(
&db,
&ctx,
"promote",
serde_json::json!({
"previous_role": current_role.to_string(),
"new_role": new_role.to_string(),
"no_change": true,
}),
)
.await?;
println!();
println!(
"✓ Promote applied to {email} (user_id={})",
ctx.target_user_id
);
println!(" Note: user already carried role={current_role}; no functional change.");
println!(" (The audit row still landed — the action remains forensically visible.)");
println!(" Audit correlation: {correlation_id}");
Ok(())
}
PromoteOutcome::Ok {
previous_role,
new_role,
revoked_session_count,
} => {
let correlation_id = write_emergency_audit(
&db,
&ctx,
"promote",
serde_json::json!({
"previous_role": previous_role.to_string(),
"new_role": new_role.to_string(),
"revoked_session_count": revoked_session_count,
}),
)
.await?;
println!();
println!(
"✓ Promoted {email} (user_id={}) {previous_role} → {new_role}",
ctx.target_user_id
);
println!(" Sessions revoked: {revoked_session_count}");
println!(" The user must re-authenticate to pick up the new tier.");
println!(" Audit correlation: {correlation_id}");
Ok(())
}
}
}
async fn emergency_access(
db: Db,
email: String,
reason_arg: String,
ttl_minutes: i64,
yes: bool,
) -> Result<(), String> {
let ctx = preflight(&db, "emergency-access", &email, &reason_arg, yes).await?;
let outcome = fw_emergency::emergency_access(&db, ctx.target_user_id, ttl_minutes)
.await
.map_err(|e| format!("emergency_access: {e}"))?;
let (token_id, url_path, expires_at, effective_ttl) = match outcome {
EmergencyAccessOutcome::Ok {
token_id,
url_path,
expires_at,
} => (token_id, url_path, expires_at, ttl_minutes.clamp(1, 60)),
EmergencyAccessOutcome::UnknownTarget => {
return Err(format!(
"User vanished between lookup and emergency_access; no token issued (email={email})"
));
}
EmergencyAccessOutcome::InactiveTarget => {
return Err(format!(
"Refused: {email} is deactivated. Emergency-access only issues URLs to active \
accounts (a URL into a deactivated account has no recovery semantic). \
Reactivate the user first via `rustio user role` or update `is_active` \
directly, then re-run."
));
}
};
let correlation_id = write_emergency_audit(
&db,
&ctx,
"emergency_access",
serde_json::json!({
"token_id": token_id,
"ttl_minutes": effective_ttl,
"expires_at": expires_at.to_rfc3339(),
}),
)
.await?;
println!();
println!(
"✓ Emergency-access URL issued for {email} (user_id={})",
ctx.target_user_id
);
println!(" Token id: {token_id}");
println!(
" Expires: {} (in {effective_ttl} minute(s))",
expires_at.to_rfc3339()
);
println!();
println!(" URL (shown once — hand to target out-of-band):");
println!();
println!(" <BASE_URL>{url_path}");
println!();
println!(" Prefix <BASE_URL> with your deployment's admin URL");
println!(" (e.g., https://admin.example.com → full URL would be");
println!(" https://admin.example.com{url_path}).");
println!(" Single-use: consuming the token writes consumed_at=NOW().");
println!(" Audit correlation: {correlation_id}");
Ok(())
}
fn build_summary(op: &str, reason: &str) -> String {
let mut preview = String::with_capacity(op.len() + 2 + 200);
preview.push_str(op);
preview.push_str(": ");
let limit = 200;
let total = reason.chars().count();
for c in reason.chars().take(limit) {
preview.push(c);
}
if total > limit {
preview.push('…');
}
preview
}
#[cfg(test)]
mod tests {
use super::build_summary;
#[test]
fn summary_short_reason_pass_through() {
let s = build_summary("reset-password", "lost MFA device");
assert_eq!(s, "reset-password: lost MFA device");
}
#[test]
fn summary_truncates_at_200_chars_with_ellipsis() {
let long = "x".repeat(250);
let s = build_summary("reset-password", &long);
assert!(s.ends_with('…'));
let body = s.trim_start_matches("reset-password: ");
let body = body.trim_end_matches('…');
assert_eq!(body.chars().count(), 200);
}
#[test]
fn summary_handles_unicode() {
let reason = "räddade en ångbåt".to_string();
let s = build_summary("reset-password", &reason);
assert_eq!(s, format!("reset-password: {reason}"));
}
}