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)]
20pub struct Identity {
21 pub user_id: i64,
22 pub email: String,
23 pub role: Role,
24 pub is_active: bool,
25 pub is_demo: bool,
29 pub demo_label: Option<String>,
30 pub must_change_password: bool,
40 pub mfa_enabled: bool,
51 pub trust_level: crate::auth::SessionTrust,
63}
64
65impl Identity {
66 pub fn is_admin(&self) -> bool {
69 self.is_active && self.role.includes(Role::Administrator)
70 }
71
72 pub fn can_access_admin(&self) -> bool {
75 self.is_active && self.role.can_access_panel()
76 }
77}
78
79pub struct StoredUser {
81 pub id: i64,
82 pub email: String,
83 pub password_hash: String,
84 pub role: Role,
85 pub is_active: bool,
86 pub is_demo: bool,
87 pub demo_label: Option<String>,
88 pub must_change_password: bool,
94 pub mfa_enabled: bool,
99}
100
101#[derive(Debug, Clone)]
106pub struct UserProfile {
107 pub id: i64,
108 pub email: String,
109 pub role: Role,
110 pub is_active: bool,
111 pub created_at: DateTime<Utc>,
112 pub full_name: Option<String>,
113 pub locale: Option<String>,
114 pub timezone: Option<String>,
115 pub is_demo: bool,
116 pub demo_label: Option<String>,
117}
118
119pub async fn init_user_tables(db: &Db) -> Result<()> {
121 sqlx::query(
122 "CREATE TABLE IF NOT EXISTS rustio_users (
123 id BIGSERIAL PRIMARY KEY,
124 email TEXT NOT NULL UNIQUE,
125 password_hash TEXT NOT NULL,
126 role TEXT NOT NULL DEFAULT 'user',
127 is_active BOOLEAN NOT NULL DEFAULT TRUE,
128 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
129 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
130 )",
131 )
132 .execute(db.pool())
133 .await?;
134
135 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
136 .execute(db.pool())
137 .await?;
138
139 Ok(())
140}
141
142pub async fn migrate_user_schema(db: &Db) -> Result<()> {
156 sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
157 .execute(db.pool())
158 .await?;
159
160 sqlx::query(
161 "ALTER TABLE rustio_users \
162 ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
163 )
164 .execute(db.pool())
165 .await?;
166 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
167 .execute(db.pool())
168 .await?;
169
170 sqlx::query(
171 "DO $$
172 BEGIN
173 IF NOT EXISTS (
174 SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
175 ) THEN
176 ALTER TABLE rustio_users
177 ADD CONSTRAINT rustio_users_role_check
178 CHECK (role IN ('user','staff','supervisor','administrator','developer'));
179 END IF;
180 END $$",
181 )
182 .execute(db.pool())
183 .await?;
184
185 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
186 .execute(db.pool())
187 .await?;
188 sqlx::query(
189 "CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
190 ON rustio_users(is_demo) WHERE is_demo = TRUE",
191 )
192 .execute(db.pool())
193 .await?;
194
195 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
196 .execute(db.pool())
197 .await?;
198 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
199 .execute(db.pool())
200 .await?;
201 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
202 .execute(db.pool())
203 .await?;
204
205 Ok(())
206}
207
208pub fn hash_password(plain: &str) -> Result<String> {
210 let salt = SaltString::generate(&mut OsRng);
211 Argon2::default()
212 .hash_password(plain.as_bytes(), &salt)
213 .map(|h| h.to_string())
214 .map_err(|e| Error::Internal(format!("password hashing: {e}")))
215}
216
217pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
219 match PasswordHash::new(stored_hash) {
220 Ok(parsed) => Argon2::default()
221 .verify_password(plain.as_bytes(), &parsed)
222 .is_ok(),
223 Err(_) => false,
224 }
225}
226
227pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
229 let hash = hash_password(password)?;
230 let row = sqlx::query(
231 "INSERT INTO rustio_users (email, password_hash, role)
232 VALUES ($1, $2, $3)
233 RETURNING id",
234 )
235 .bind(email)
236 .bind(&hash)
237 .bind(role.as_str())
238 .fetch_one(db.pool())
239 .await
240 .map_err(|e| {
241 log::warn!("create_user failed for {email}: {e}");
246 let detail = e.to_string();
247 if detail.contains("rustio_users_email_key") {
248 Error::BadRequest("An account with this email already exists.".into())
249 } else {
250 Error::BadRequest("Could not create user. Please check your input.".into())
251 }
252 })?;
253 let id: i64 = row
254 .try_get("id")
255 .map_err(|e| Error::Internal(format!("returning id: {e}")))?;
256 Ok(id)
257}
258
259pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
261 let row = sqlx::query(
262 "SELECT id, email, password_hash, role, is_active, is_demo, demo_label, \
263 must_change_password, mfa_enabled \
264 FROM rustio_users \
265 WHERE email = $1",
266 )
267 .bind(email)
268 .fetch_optional(db.pool())
269 .await?;
270 match row {
271 Some(r) => {
272 let r = Row::from_pg(&r);
273 Ok(Some(StoredUser {
274 id: r.get_i64("id")?,
275 email: r.get_string("email")?,
276 password_hash: r.get_string("password_hash")?,
277 role: Role::parse(&r.get_string("role")?)?,
278 is_active: r.get_bool("is_active")?,
279 is_demo: r.get_bool("is_demo")?,
280 demo_label: r.get_optional_string("demo_label")?,
281 must_change_password: r.get_bool("must_change_password")?,
282 mfa_enabled: r.get_bool("mfa_enabled")?,
283 }))
284 }
285 None => Ok(None),
286 }
287}
288
289pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
294 let row = sqlx::query(
295 "SELECT id, email, role, is_active, created_at,
296 full_name, locale, timezone, is_demo, demo_label
297 FROM rustio_users
298 WHERE id = $1",
299 )
300 .bind(user_id)
301 .fetch_optional(db.pool())
302 .await?;
303 match row {
304 Some(r) => {
305 let r = Row::from_pg(&r);
306 Ok(Some(UserProfile {
307 id: r.get_i64("id")?,
308 email: r.get_string("email")?,
309 role: Role::parse(&r.get_string("role")?)?,
310 is_active: r.get_bool("is_active")?,
311 created_at: r.get_datetime("created_at")?,
312 full_name: r.get_optional_string("full_name")?,
313 locale: r.get_optional_string("locale")?,
314 timezone: r.get_optional_string("timezone")?,
315 is_demo: r.get_bool("is_demo")?,
316 demo_label: r.get_optional_string("demo_label")?,
317 }))
318 }
319 None => Ok(None),
320 }
321}
322
323pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
345 let hash = hash_password(new_password)?;
346 sqlx::query(
347 "UPDATE rustio_users \
348 SET password_hash = $1, password_changed_at = $2, updated_at = $2 \
349 WHERE id = $3",
350 )
351 .bind(&hash)
352 .bind(Utc::now())
353 .bind(user_id)
354 .execute(db.pool())
355 .await?;
356 Ok(())
357}
358
359pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
361 sqlx::query("UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3")
362 .bind(role.as_str())
363 .bind(Utc::now())
364 .bind(user_id)
365 .execute(db.pool())
366 .await?;
367 Ok(())
368}
369
370pub fn verdict_for_orphan_role(
380 active_count_in_protected: i64,
381 target_is_in_protected: bool,
382 new_role_is_protected: bool,
383 new_active: bool,
384) -> bool {
385 if !target_is_in_protected {
386 return false;
387 }
388 if active_count_in_protected != 1 {
389 return false;
390 }
391 !(new_active && new_role_is_protected)
394}
395
396pub async fn would_orphan_role(
410 db: &Db,
411 user_id: i64,
412 protected_role: Role,
413 new_role: Role,
414 new_active: bool,
415) -> Result<bool> {
416 let active_count: i64 = sqlx::query_scalar(
417 "SELECT COUNT(*) FROM rustio_users WHERE role = $1 AND is_active = TRUE",
418 )
419 .bind(protected_role.as_str())
420 .fetch_one(db.pool())
421 .await?;
422
423 let target_role_str: Option<String> =
424 sqlx::query_scalar("SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE")
425 .bind(user_id)
426 .fetch_optional(db.pool())
427 .await?;
428 let target_is_in_protected = target_role_str.as_deref() == Some(protected_role.as_str());
429
430 Ok(verdict_for_orphan_role(
431 active_count,
432 target_is_in_protected,
433 new_role == protected_role,
434 new_active,
435 ))
436}
437
438pub async fn would_orphan_protected(
443 db: &Db,
444 user_id: i64,
445 new_role: Role,
446 new_active: bool,
447) -> Result<Option<Role>> {
448 for &role in super::role::protected_roles() {
449 if would_orphan_role(db, user_id, role, new_role, new_active).await? {
450 return Ok(Some(role));
451 }
452 }
453 Ok(None)
454}
455
456#[deprecated(
461 since = "0.3.0",
462 note = "use `would_orphan_protected` to cover every protected role, not just Developer"
463)]
464pub async fn would_orphan_developers(
465 db: &Db,
466 user_id: i64,
467 new_role: Option<Role>,
468) -> Result<bool> {
469 let (role, active) = match new_role {
470 Some(r) => (r, true),
471 None => (Role::User, false),
472 };
473 would_orphan_role(db, user_id, Role::Developer, role, active).await
474}
475
476pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
481 let user = find_user_by_email(db, email)
482 .await?
483 .ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
484 if !user.is_active {
485 return Err(Error::Forbidden("account disabled".into()));
486 }
487 if !verify_password(password, &user.password_hash) {
488 return Err(Error::Unauthorized("invalid email or password".into()));
489 }
490 create_session(db, user.id).await
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn user_profile_derives_debug_and_clone() {
499 fn assert_traits<T: std::fmt::Debug + Clone>() {}
500 assert_traits::<UserProfile>();
501 }
502
503 #[test]
504 fn password_round_trip() {
505 let h = hash_password("secret").unwrap();
506 assert!(verify_password("secret", &h));
507 assert!(!verify_password("wrong", &h));
508 }
509
510 #[test]
513 fn verdict_safe_when_target_not_in_protected_pool() {
514 assert!(!verdict_for_orphan_role(0, false, false, true));
517 assert!(!verdict_for_orphan_role(1, false, false, false));
518 assert!(!verdict_for_orphan_role(5, false, true, true));
519 }
520
521 #[test]
522 fn verdict_safe_when_more_than_one_member() {
523 assert!(!verdict_for_orphan_role(2, true, false, true));
526 assert!(!verdict_for_orphan_role(5, true, false, false));
527 }
528
529 #[test]
530 fn verdict_blocks_when_last_member_demoting() {
531 assert!(verdict_for_orphan_role(1, true, false, true));
534 }
535
536 #[test]
537 fn verdict_blocks_when_last_member_deactivating() {
538 assert!(verdict_for_orphan_role(1, true, true, false));
540 }
541
542 #[test]
543 fn verdict_blocks_when_last_member_deleting() {
544 assert!(verdict_for_orphan_role(1, true, false, false));
546 }
547
548 #[test]
549 fn verdict_safe_when_last_member_keeps_role() {
550 assert!(!verdict_for_orphan_role(1, true, true, true));
552 }
553}