1use argon2::password_hash::{
4 rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
5};
6use argon2::Argon2;
7use chrono::{DateTime, Utc};
8use sqlx::Row as SqlxRow;
9
10use crate::error::{Error, Result};
11use crate::orm::{Db, Row};
12
13use super::role::Role;
14use super::sessions::create_session;
15
16#[derive(Debug, Clone)]
19pub struct Identity {
20 pub user_id: i64,
21 pub email: String,
22 pub role: Role,
23 pub is_active: bool,
24 pub is_demo: bool,
28 pub demo_label: Option<String>,
29 pub must_change_password: bool,
39 pub mfa_enabled: bool,
50 pub trust_level: crate::auth::SessionTrust,
62}
63
64impl Identity {
65 pub fn is_admin(&self) -> bool {
67 self.is_active && self.role.includes(Role::Administrator)
68 }
69
70 pub fn can_access_admin(&self) -> bool {
72 self.is_active && self.role.can_access_panel()
73 }
74}
75
76pub struct StoredUser {
77 pub id: i64,
78 pub email: String,
79 pub password_hash: String,
80 pub role: Role,
81 pub is_active: bool,
82 pub is_demo: bool,
83 pub demo_label: Option<String>,
84 pub must_change_password: bool,
90 pub mfa_enabled: bool,
95}
96
97#[derive(Debug, Clone)]
101pub struct UserProfile {
102 pub id: i64,
103 pub email: String,
104 pub role: Role,
105 pub is_active: bool,
106 pub created_at: DateTime<Utc>,
107 pub full_name: Option<String>,
108 pub locale: Option<String>,
109 pub timezone: Option<String>,
110 pub is_demo: bool,
111 pub demo_label: Option<String>,
112}
113
114pub async fn init_user_tables(db: &Db) -> Result<()> {
115 sqlx::query(
116 "CREATE TABLE IF NOT EXISTS rustio_users (
117 id BIGSERIAL PRIMARY KEY,
118 email TEXT NOT NULL UNIQUE,
119 password_hash TEXT NOT NULL,
120 role TEXT NOT NULL DEFAULT 'user',
121 is_active BOOLEAN NOT NULL DEFAULT TRUE,
122 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
123 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
124 )",
125 )
126 .execute(db.pool())
127 .await?;
128
129 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
130 .execute(db.pool())
131 .await?;
132
133 Ok(())
134}
135
136pub async fn migrate_user_schema(db: &Db) -> Result<()> {
149 sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
150 .execute(db.pool())
151 .await?;
152
153 sqlx::query(
154 "ALTER TABLE rustio_users \
155 ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
156 )
157 .execute(db.pool())
158 .await?;
159 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
160 .execute(db.pool())
161 .await?;
162
163 sqlx::query(
164 "DO $$
165 BEGIN
166 IF NOT EXISTS (
167 SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
168 ) THEN
169 ALTER TABLE rustio_users
170 ADD CONSTRAINT rustio_users_role_check
171 CHECK (role IN ('user','staff','supervisor','administrator','developer'));
172 END IF;
173 END $$",
174 )
175 .execute(db.pool())
176 .await?;
177
178 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
179 .execute(db.pool())
180 .await?;
181 sqlx::query(
182 "CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
183 ON rustio_users(is_demo) WHERE is_demo = TRUE",
184 )
185 .execute(db.pool())
186 .await?;
187
188 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
189 .execute(db.pool())
190 .await?;
191 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
192 .execute(db.pool())
193 .await?;
194 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
195 .execute(db.pool())
196 .await?;
197
198 Ok(())
199}
200
201pub fn hash_password(plain: &str) -> Result<String> {
202 let salt = SaltString::generate(&mut OsRng);
203 Argon2::default()
204 .hash_password(plain.as_bytes(), &salt)
205 .map(|h| h.to_string())
206 .map_err(|e| Error::Internal(format!("password hashing: {e}")))
207}
208
209pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
210 match PasswordHash::new(stored_hash) {
211 Ok(parsed) => Argon2::default()
212 .verify_password(plain.as_bytes(), &parsed)
213 .is_ok(),
214 Err(_) => false,
215 }
216}
217
218pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
219 let hash = hash_password(password)?;
220 let row = sqlx::query(
221 "INSERT INTO rustio_users (email, password_hash, role)
222 VALUES ($1, $2, $3)
223 RETURNING id",
224 )
225 .bind(email)
226 .bind(&hash)
227 .bind(role.as_str())
228 .fetch_one(db.pool())
229 .await
230 .map_err(|e| {
231 log::warn!("create_user failed for {email}: {e}");
236 let detail = e.to_string();
237 if detail.contains("rustio_users_email_key") {
238 Error::BadRequest("An account with this email already exists.".into())
239 } else {
240 Error::BadRequest("Could not create user. Please check your input.".into())
241 }
242 })?;
243 let id: i64 = row
244 .try_get("id")
245 .map_err(|e| Error::Internal(format!("returning id: {e}")))?;
246 Ok(id)
247}
248
249pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
250 let row = sqlx::query(
251 "SELECT id, email, password_hash, role, is_active, is_demo, demo_label, \
252 must_change_password, mfa_enabled \
253 FROM rustio_users \
254 WHERE email = $1",
255 )
256 .bind(email)
257 .fetch_optional(db.pool())
258 .await?;
259 match row {
260 Some(r) => {
261 let r = Row::from_pg(&r);
262 Ok(Some(StoredUser {
263 id: r.get_i64("id")?,
264 email: r.get_string("email")?,
265 password_hash: r.get_string("password_hash")?,
266 role: Role::parse(&r.get_string("role")?)?,
267 is_active: r.get_bool("is_active")?,
268 is_demo: r.get_bool("is_demo")?,
269 demo_label: r.get_optional_string("demo_label")?,
270 must_change_password: r.get_bool("must_change_password")?,
271 mfa_enabled: r.get_bool("mfa_enabled")?,
272 }))
273 }
274 None => Ok(None),
275 }
276}
277
278pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
282 let row = sqlx::query(
283 "SELECT id, email, role, is_active, created_at,
284 full_name, locale, timezone, is_demo, demo_label
285 FROM rustio_users
286 WHERE id = $1",
287 )
288 .bind(user_id)
289 .fetch_optional(db.pool())
290 .await?;
291 match row {
292 Some(r) => {
293 let r = Row::from_pg(&r);
294 Ok(Some(UserProfile {
295 id: r.get_i64("id")?,
296 email: r.get_string("email")?,
297 role: Role::parse(&r.get_string("role")?)?,
298 is_active: r.get_bool("is_active")?,
299 created_at: r.get_datetime("created_at")?,
300 full_name: r.get_optional_string("full_name")?,
301 locale: r.get_optional_string("locale")?,
302 timezone: r.get_optional_string("timezone")?,
303 is_demo: r.get_bool("is_demo")?,
304 demo_label: r.get_optional_string("demo_label")?,
305 }))
306 }
307 None => Ok(None),
308 }
309}
310
311pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
332 let hash = hash_password(new_password)?;
333 sqlx::query(
334 "UPDATE rustio_users \
335 SET password_hash = $1, password_changed_at = $2, updated_at = $2 \
336 WHERE id = $3",
337 )
338 .bind(&hash)
339 .bind(Utc::now())
340 .bind(user_id)
341 .execute(db.pool())
342 .await?;
343 Ok(())
344}
345
346pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
347 sqlx::query("UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3")
348 .bind(role.as_str())
349 .bind(Utc::now())
350 .bind(user_id)
351 .execute(db.pool())
352 .await?;
353 Ok(())
354}
355
356pub fn verdict_for_orphan_role(
365 active_count_in_protected: i64,
366 target_is_in_protected: bool,
367 new_role_is_protected: bool,
368 new_active: bool,
369) -> bool {
370 if !target_is_in_protected {
371 return false;
372 }
373 if active_count_in_protected != 1 {
374 return false;
375 }
376 !(new_active && new_role_is_protected)
379}
380
381pub async fn would_orphan_role(
394 db: &Db,
395 user_id: i64,
396 protected_role: Role,
397 new_role: Role,
398 new_active: bool,
399) -> Result<bool> {
400 let active_count: i64 = sqlx::query_scalar(
401 "SELECT COUNT(*) FROM rustio_users WHERE role = $1 AND is_active = TRUE",
402 )
403 .bind(protected_role.as_str())
404 .fetch_one(db.pool())
405 .await?;
406
407 let target_role_str: Option<String> =
408 sqlx::query_scalar("SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE")
409 .bind(user_id)
410 .fetch_optional(db.pool())
411 .await?;
412 let target_is_in_protected = target_role_str.as_deref() == Some(protected_role.as_str());
413
414 Ok(verdict_for_orphan_role(
415 active_count,
416 target_is_in_protected,
417 new_role == protected_role,
418 new_active,
419 ))
420}
421
422pub async fn would_orphan_protected(
426 db: &Db,
427 user_id: i64,
428 new_role: Role,
429 new_active: bool,
430) -> Result<Option<Role>> {
431 for &role in super::role::protected_roles() {
432 if would_orphan_role(db, user_id, role, new_role, new_active).await? {
433 return Ok(Some(role));
434 }
435 }
436 Ok(None)
437}
438
439#[deprecated(
443 since = "0.3.0",
444 note = "use `would_orphan_protected` to cover every protected role, not just Developer"
445)]
446pub async fn would_orphan_developers(
447 db: &Db,
448 user_id: i64,
449 new_role: Option<Role>,
450) -> Result<bool> {
451 let (role, active) = match new_role {
452 Some(r) => (r, true),
453 None => (Role::User, false),
454 };
455 would_orphan_role(db, user_id, Role::Developer, role, active).await
456}
457
458pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
462 let user = find_user_by_email(db, email)
463 .await?
464 .ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
465 if !user.is_active {
466 return Err(Error::Forbidden("account disabled".into()));
467 }
468 if !verify_password(password, &user.password_hash) {
469 return Err(Error::Unauthorized("invalid email or password".into()));
470 }
471 create_session(db, user.id).await
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn user_profile_derives_debug_and_clone() {
480 fn assert_traits<T: std::fmt::Debug + Clone>() {}
481 assert_traits::<UserProfile>();
482 }
483
484 #[test]
485 fn password_round_trip() {
486 let h = hash_password("secret").unwrap();
487 assert!(verify_password("secret", &h));
488 assert!(!verify_password("wrong", &h));
489 }
490
491 #[test]
494 fn verdict_safe_when_target_not_in_protected_pool() {
495 assert!(!verdict_for_orphan_role(0, false, false, true));
498 assert!(!verdict_for_orphan_role(1, false, false, false));
499 assert!(!verdict_for_orphan_role(5, false, true, true));
500 }
501
502 #[test]
503 fn verdict_safe_when_more_than_one_member() {
504 assert!(!verdict_for_orphan_role(2, true, false, true));
507 assert!(!verdict_for_orphan_role(5, true, false, false));
508 }
509
510 #[test]
511 fn verdict_blocks_when_last_member_demoting() {
512 assert!(verdict_for_orphan_role(1, true, false, true));
515 }
516
517 #[test]
518 fn verdict_blocks_when_last_member_deactivating() {
519 assert!(verdict_for_orphan_role(1, true, true, false));
521 }
522
523 #[test]
524 fn verdict_blocks_when_last_member_deleting() {
525 assert!(verdict_for_orphan_role(1, true, false, false));
527 }
528
529 #[test]
530 fn verdict_safe_when_last_member_keeps_role() {
531 assert!(!verdict_for_orphan_role(1, true, true, true));
533 }
534}