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,
Editor,
Viewer,
}
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,
CliRole::Editor | CliRole::Viewer => Role::User,
}
}
}
impl CliRole {
pub fn default_group_name(&self) -> Option<&'static str> {
match self {
CliRole::Administrator => Some("administrator"),
CliRole::Editor => Some("editor"),
CliRole::Viewer => Some("viewer"),
CliRole::User | CliRole::Staff | CliRole::Supervisor | CliRole::Developer => None,
}
}
}
#[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,
Perms {
#[arg(long)]
email: String,
},
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,
password,
first_name,
last_name,
display_name,
job_title,
)
.await
}
Action::List => list(db).await,
Action::Perms { email } => perms(db, email).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,
cli_role: CliRole,
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 role: Role = cli_role.into();
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}");
if let Some(group_name) = cli_role.default_group_name() {
let group_id: Option<i64> =
sqlx::query_scalar("SELECT id FROM rustio_groups WHERE name = $1")
.bind(group_name)
.fetch_optional(db.pool())
.await
.map_err(|e| format!("lookup group `{group_name}`: {e}"))?;
if let Some(gid) = group_id {
auth::add_user_to_group(&db, id, gid)
.await
.map_err(|e| format!("add to group `{group_name}`: {e}"))?;
println!("Added to group: {group_name}");
}
}
if crate::style::is_interactive() {
crate::style::next_step(
"launch your app and sign in",
&[(
"cargo run".to_string(),
format!(
"{} {}",
crate::style::hint("→"),
crate::style::url("http://127.0.0.1:8000/admin")
),
)],
);
}
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(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PermsReport {
email: String,
id: i64,
role_label: String,
is_active: bool,
is_demo: bool,
bypasses_group_checks: bool,
groups: Vec<String>,
direct_perms: Vec<String>,
group_perms_by_group: Vec<(String, Vec<String>)>,
}
impl PermsReport {
fn effective_perms(&self) -> Vec<String> {
use std::collections::BTreeSet;
let mut set: BTreeSet<String> = BTreeSet::new();
set.extend(self.direct_perms.iter().cloned());
for (_, perms) in &self.group_perms_by_group {
set.extend(perms.iter().cloned());
}
set.into_iter().collect()
}
}
async fn perms(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}"))?;
let groups = sqlx::query(
"SELECT g.name
FROM rustio_groups g
JOIN rustio_user_groups ug ON ug.group_id = g.id
WHERE ug.user_id = $1
ORDER BY g.name",
)
.bind(user.id)
.fetch_all(db.pool())
.await
.map_err(|e| format!("groups query: {e}"))?;
let direct = sqlx::query(
"SELECT p.name
FROM rustio_permissions p
JOIN rustio_user_permissions up ON up.permission_id = p.id
WHERE up.user_id = $1
ORDER BY p.name",
)
.bind(user.id)
.fetch_all(db.pool())
.await
.map_err(|e| format!("direct-perms query: {e}"))?;
let group_pairs = sqlx::query(
"SELECT g.name AS group_name, p.name AS perm_name
FROM rustio_permissions p
JOIN rustio_group_permissions gp ON gp.permission_id = p.id
JOIN rustio_groups g ON g.id = gp.group_id
JOIN rustio_user_groups ug ON ug.group_id = gp.group_id
WHERE ug.user_id = $1
ORDER BY g.name, p.name",
)
.bind(user.id)
.fetch_all(db.pool())
.await
.map_err(|e| format!("group-perms query: {e}"))?;
let groups: Vec<String> = groups
.into_iter()
.filter_map(|r| r.try_get::<String, _>("name").ok())
.collect();
let direct_perms: Vec<String> = direct
.into_iter()
.filter_map(|r| r.try_get::<String, _>("name").ok())
.collect();
let mut group_perms_by_group: Vec<(String, Vec<String>)> = Vec::new();
for row in group_pairs {
let g: String = row.try_get("group_name").unwrap_or_default();
let p: String = row.try_get("perm_name").unwrap_or_default();
match group_perms_by_group.last_mut() {
Some((last, perms)) if last == &g => perms.push(p),
_ => group_perms_by_group.push((g, vec![p])),
}
}
let report = PermsReport {
email: user.email,
id: user.id,
role_label: user.role.label().to_string(),
is_active: user.is_active,
is_demo: user.is_demo,
bypasses_group_checks: user.role.bypasses_group_checks(),
groups,
direct_perms,
group_perms_by_group,
};
print!("{}", format_perms_report(&report));
Ok(())
}
fn format_perms_report(r: &PermsReport) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "User: {}", r.email);
let _ = writeln!(out, "ID: {}", r.id);
let role_line = if r.bypasses_group_checks {
format!("{} (bypasses group checks)", r.role_label)
} else {
r.role_label.clone()
};
let _ = writeln!(out, "Role: {role_line}");
let _ = writeln!(
out,
"Active: {}",
if r.is_active {
"yes"
} else {
"no -- every check_permission call denies"
}
);
if r.is_demo {
let _ = writeln!(out, "Demo: yes");
}
out.push('\n');
let _ = writeln!(out, "Groups:");
if r.groups.is_empty() {
let _ = writeln!(out, " (none)");
} else {
for g in &r.groups {
let _ = writeln!(out, " {g}");
}
}
out.push('\n');
let _ = writeln!(out, "Direct permissions (rustio_user_permissions):");
if r.direct_perms.is_empty() {
let _ = writeln!(out, " (none)");
} else {
for p in &r.direct_perms {
let _ = writeln!(out, " {p}");
}
}
out.push('\n');
let _ = writeln!(out, "Group permissions (inherited via memberships):");
if r.group_perms_by_group.is_empty() {
let _ = writeln!(out, " (none)");
} else {
for (g, perms) in &r.group_perms_by_group {
let _ = writeln!(out, " via \"{g}\":");
for p in perms {
let _ = writeln!(out, " {p}");
}
}
}
out.push('\n');
let _ = writeln!(
out,
"Effective permissions (what check_permission honours):"
);
if r.bypasses_group_checks {
let _ = writeln!(out, " ★ all permissions (role bypasses group checks) ★");
} else if !r.is_active {
let _ = writeln!(out, " (none -- user is inactive, every check denies)");
} else {
let eff = r.effective_perms();
if eff.is_empty() {
let _ = writeln!(out, " (none)");
} else {
for p in &eff {
let _ = writeln!(out, " {p}");
}
let _ = writeln!(out, " ({} total)", eff.len());
}
}
out
}
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-admin 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, format_perms_report, CliRole, PermsReport};
#[test]
fn lockstep_default_groups_match_cli_role_names() {
use rustio_admin::auth::DEFAULT_GROUP_NAMES;
use std::collections::BTreeSet;
let groups: BTreeSet<&'static str> = DEFAULT_GROUP_NAMES.iter().copied().collect();
let cli_roles: BTreeSet<&'static str> = [
CliRole::User,
CliRole::Staff,
CliRole::Supervisor,
CliRole::Administrator,
CliRole::Developer,
CliRole::Editor,
CliRole::Viewer,
]
.iter()
.filter_map(|r| r.default_group_name())
.collect();
assert_eq!(
groups, cli_roles,
"lockstep drift: rustio_admin::auth::DEFAULT_GROUP_NAMES ({groups:?}) \
must equal the set of CliRole values that return Some from \
default_group_name() ({cli_roles:?})"
);
let expected: BTreeSet<&'static str> =
["administrator", "editor", "viewer"].into_iter().collect();
assert_eq!(
groups, expected,
"doctrine guard: the seeded default groups should be exactly \
{{administrator, editor, viewer}} per DESIGN_PERMISSIONS.md"
);
}
fn base_report() -> PermsReport {
PermsReport {
email: "alice@example.test".into(),
id: 42,
role_label: "user".into(),
is_active: true,
is_demo: false,
bypasses_group_checks: false,
groups: vec![],
direct_perms: vec![],
group_perms_by_group: vec![],
}
}
#[test]
fn perms_report_admin_bypass_shows_all_permissions_marker() {
let r = PermsReport {
role_label: "administrator".into(),
bypasses_group_checks: true,
..base_report()
};
let out = format_perms_report(&r);
assert!(out.contains("administrator (bypasses group checks)"));
assert!(out.contains("★ all permissions (role bypasses group checks) ★"));
}
#[test]
fn perms_report_inactive_user_shows_deny_marker() {
let r = PermsReport {
is_active: false,
direct_perms: vec!["posts.view_post".into()],
..base_report()
};
let out = format_perms_report(&r);
assert!(out.contains("Active: no -- every check_permission call denies"));
assert!(out.contains("(none -- user is inactive, every check denies)"));
assert!(out.contains("posts.view_post"));
}
#[test]
fn perms_report_empty_user_uses_none_markers() {
let r = base_report();
let out = format_perms_report(&r);
assert!(out.contains("Groups:\n (none)"));
assert!(out.contains("Direct permissions (rustio_user_permissions):\n (none)"));
assert!(out.contains("Group permissions (inherited via memberships):\n (none)"));
assert!(out.contains("Effective permissions (what check_permission honours):\n (none)"));
}
#[test]
fn perms_report_effective_unions_direct_and_group() {
let r = PermsReport {
groups: vec!["Editors".into()],
direct_perms: vec!["posts.view_post".into()],
group_perms_by_group: vec![(
"Editors".into(),
vec!["posts.change_post".into(), "posts.view_post".into()],
)],
..base_report()
};
let out = format_perms_report(&r);
assert!(out.contains("Groups:\n Editors"));
assert!(out.contains("Direct permissions"));
assert!(out.contains(" posts.view_post"));
assert!(out.contains(" via \"Editors\":"));
assert!(out.contains(" posts.change_post"));
let effective_block = out
.split_once("Effective permissions")
.expect("effective section exists")
.1;
assert_eq!(effective_block.matches("posts.view_post").count(), 1);
assert!(effective_block.contains("posts.change_post"));
assert!(effective_block.contains("(2 total)"));
}
#[test]
fn perms_report_admin_with_grants_still_lists_them_for_transparency() {
let r = PermsReport {
role_label: "administrator".into(),
bypasses_group_checks: true,
direct_perms: vec!["posts.delete_post".into()],
..base_report()
};
let out = format_perms_report(&r);
assert!(out.contains(" posts.delete_post"));
assert!(out.contains("★ all permissions"));
}
#[test]
fn perms_report_demo_user_shows_demo_marker() {
let r = PermsReport {
is_demo: true,
..base_report()
};
let out = format_perms_report(&r);
assert!(out.contains("Demo: yes"));
}
#[test]
fn effective_perms_deduplicates_across_groups() {
let r = PermsReport {
group_perms_by_group: vec![
("Editors".into(), vec!["posts.view_post".into()]),
("Reviewers".into(), vec!["posts.view_post".into()]),
],
..base_report()
};
let eff = r.effective_perms();
assert_eq!(eff, vec!["posts.view_post".to_string()]);
}
#[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}"));
}
}