use argon2::password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
};
use argon2::Argon2;
use chrono::{DateTime, Utc};
use sqlx::Row as SqlxRow;
use crate::error::{Error, Result};
use crate::orm::{Db, Row};
use super::role::Role;
use super::sessions::create_session;
#[derive(Debug, Clone)]
pub struct Identity {
pub user_id: i64,
pub email: String,
pub role: Role,
pub is_active: bool,
pub is_demo: bool,
pub demo_label: Option<String>,
pub must_change_password: bool,
pub mfa_enabled: bool,
pub trust_level: crate::auth::SessionTrust,
}
impl Identity {
pub fn is_admin(&self) -> bool {
self.is_active && self.role.includes(Role::Administrator)
}
pub fn can_access_admin(&self) -> bool {
self.is_active && self.role.can_access_panel()
}
}
pub struct StoredUser {
pub id: i64,
pub email: String,
pub password_hash: String,
pub role: Role,
pub is_active: bool,
pub is_demo: bool,
pub demo_label: Option<String>,
pub must_change_password: bool,
pub mfa_enabled: bool,
}
#[derive(Debug, Clone)]
pub struct UserProfile {
pub id: i64,
pub email: String,
pub role: Role,
pub is_active: bool,
pub created_at: DateTime<Utc>,
pub full_name: Option<String>,
pub locale: Option<String>,
pub timezone: Option<String>,
pub is_demo: bool,
pub demo_label: Option<String>,
}
pub async fn init_user_tables(db: &Db) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS rustio_users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)",
)
.execute(db.pool())
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
.execute(db.pool())
.await?;
Ok(())
}
pub async fn migrate_user_schema(db: &Db) -> Result<()> {
sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
.execute(db.pool())
.await?;
sqlx::query(
"ALTER TABLE rustio_users \
ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
)
.execute(db.pool())
.await?;
sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
.execute(db.pool())
.await?;
sqlx::query(
"DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
) THEN
ALTER TABLE rustio_users
ADD CONSTRAINT rustio_users_role_check
CHECK (role IN ('user','staff','supervisor','administrator','developer'));
END IF;
END $$",
)
.execute(db.pool())
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
.execute(db.pool())
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
ON rustio_users(is_demo) WHERE is_demo = TRUE",
)
.execute(db.pool())
.await?;
sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
.execute(db.pool())
.await?;
sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
.execute(db.pool())
.await?;
sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
.execute(db.pool())
.await?;
Ok(())
}
pub fn hash_password(plain: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(plain.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| Error::Internal(format!("password hashing: {e}")))
}
pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
match PasswordHash::new(stored_hash) {
Ok(parsed) => Argon2::default()
.verify_password(plain.as_bytes(), &parsed)
.is_ok(),
Err(_) => false,
}
}
pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
let hash = hash_password(password)?;
let row = sqlx::query(
"INSERT INTO rustio_users (email, password_hash, role)
VALUES ($1, $2, $3)
RETURNING id",
)
.bind(email)
.bind(&hash)
.bind(role.as_str())
.fetch_one(db.pool())
.await
.map_err(|e| {
log::warn!("create_user failed for {email}: {e}");
let detail = e.to_string();
if detail.contains("rustio_users_email_key") {
Error::BadRequest("An account with this email already exists.".into())
} else {
Error::BadRequest("Could not create user. Please check your input.".into())
}
})?;
let id: i64 = row
.try_get("id")
.map_err(|e| Error::Internal(format!("returning id: {e}")))?;
Ok(id)
}
pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
let row = sqlx::query(
"SELECT id, email, password_hash, role, is_active, is_demo, demo_label, \
must_change_password, mfa_enabled \
FROM rustio_users \
WHERE email = $1",
)
.bind(email)
.fetch_optional(db.pool())
.await?;
match row {
Some(r) => {
let r = Row::from_pg(&r);
Ok(Some(StoredUser {
id: r.get_i64("id")?,
email: r.get_string("email")?,
password_hash: r.get_string("password_hash")?,
role: Role::parse(&r.get_string("role")?)?,
is_active: r.get_bool("is_active")?,
is_demo: r.get_bool("is_demo")?,
demo_label: r.get_optional_string("demo_label")?,
must_change_password: r.get_bool("must_change_password")?,
mfa_enabled: r.get_bool("mfa_enabled")?,
}))
}
None => Ok(None),
}
}
pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
let row = sqlx::query(
"SELECT id, email, role, is_active, created_at,
full_name, locale, timezone, is_demo, demo_label
FROM rustio_users
WHERE id = $1",
)
.bind(user_id)
.fetch_optional(db.pool())
.await?;
match row {
Some(r) => {
let r = Row::from_pg(&r);
Ok(Some(UserProfile {
id: r.get_i64("id")?,
email: r.get_string("email")?,
role: Role::parse(&r.get_string("role")?)?,
is_active: r.get_bool("is_active")?,
created_at: r.get_datetime("created_at")?,
full_name: r.get_optional_string("full_name")?,
locale: r.get_optional_string("locale")?,
timezone: r.get_optional_string("timezone")?,
is_demo: r.get_bool("is_demo")?,
demo_label: r.get_optional_string("demo_label")?,
}))
}
None => Ok(None),
}
}
pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
let hash = hash_password(new_password)?;
sqlx::query(
"UPDATE rustio_users \
SET password_hash = $1, password_changed_at = $2, updated_at = $2 \
WHERE id = $3",
)
.bind(&hash)
.bind(Utc::now())
.bind(user_id)
.execute(db.pool())
.await?;
Ok(())
}
pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
sqlx::query("UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3")
.bind(role.as_str())
.bind(Utc::now())
.bind(user_id)
.execute(db.pool())
.await?;
Ok(())
}
pub fn verdict_for_orphan_role(
active_count_in_protected: i64,
target_is_in_protected: bool,
new_role_is_protected: bool,
new_active: bool,
) -> bool {
if !target_is_in_protected {
return false;
}
if active_count_in_protected != 1 {
return false;
}
!(new_active && new_role_is_protected)
}
pub async fn would_orphan_role(
db: &Db,
user_id: i64,
protected_role: Role,
new_role: Role,
new_active: bool,
) -> Result<bool> {
let active_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM rustio_users WHERE role = $1 AND is_active = TRUE",
)
.bind(protected_role.as_str())
.fetch_one(db.pool())
.await?;
let target_role_str: Option<String> =
sqlx::query_scalar("SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE")
.bind(user_id)
.fetch_optional(db.pool())
.await?;
let target_is_in_protected = target_role_str.as_deref() == Some(protected_role.as_str());
Ok(verdict_for_orphan_role(
active_count,
target_is_in_protected,
new_role == protected_role,
new_active,
))
}
pub async fn would_orphan_protected(
db: &Db,
user_id: i64,
new_role: Role,
new_active: bool,
) -> Result<Option<Role>> {
for &role in super::role::protected_roles() {
if would_orphan_role(db, user_id, role, new_role, new_active).await? {
return Ok(Some(role));
}
}
Ok(None)
}
#[deprecated(
since = "0.3.0",
note = "use `would_orphan_protected` to cover every protected role, not just Developer"
)]
pub async fn would_orphan_developers(
db: &Db,
user_id: i64,
new_role: Option<Role>,
) -> Result<bool> {
let (role, active) = match new_role {
Some(r) => (r, true),
None => (Role::User, false),
};
would_orphan_role(db, user_id, Role::Developer, role, active).await
}
pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
let user = find_user_by_email(db, email)
.await?
.ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
if !user.is_active {
return Err(Error::Forbidden("account disabled".into()));
}
if !verify_password(password, &user.password_hash) {
return Err(Error::Unauthorized("invalid email or password".into()));
}
create_session(db, user.id).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_profile_derives_debug_and_clone() {
fn assert_traits<T: std::fmt::Debug + Clone>() {}
assert_traits::<UserProfile>();
}
#[test]
fn password_round_trip() {
let h = hash_password("secret").unwrap();
assert!(verify_password("secret", &h));
assert!(!verify_password("wrong", &h));
}
#[test]
fn verdict_safe_when_target_not_in_protected_pool() {
assert!(!verdict_for_orphan_role(0, false, false, true));
assert!(!verdict_for_orphan_role(1, false, false, false));
assert!(!verdict_for_orphan_role(5, false, true, true));
}
#[test]
fn verdict_safe_when_more_than_one_member() {
assert!(!verdict_for_orphan_role(2, true, false, true));
assert!(!verdict_for_orphan_role(5, true, false, false));
}
#[test]
fn verdict_blocks_when_last_member_demoting() {
assert!(verdict_for_orphan_role(1, true, false, true));
}
#[test]
fn verdict_blocks_when_last_member_deactivating() {
assert!(verdict_for_orphan_role(1, true, true, false));
}
#[test]
fn verdict_blocks_when_last_member_deleting() {
assert!(verdict_for_orphan_role(1, true, false, false));
}
#[test]
fn verdict_safe_when_last_member_keeps_role() {
assert!(!verdict_for_orphan_role(1, true, true, true));
}
}