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}
30
31impl Identity {
32 pub fn is_admin(&self) -> bool {
34 self.is_active && self.role.includes(Role::Administrator)
35 }
36
37 pub fn can_access_admin(&self) -> bool {
39 self.is_active && self.role.can_access_panel()
40 }
41}
42
43pub struct StoredUser {
44 pub id: i64,
45 pub email: String,
46 pub password_hash: String,
47 pub role: Role,
48 pub is_active: bool,
49 pub is_demo: bool,
50 pub demo_label: Option<String>,
51}
52
53#[derive(Debug, Clone)]
57pub struct UserProfile {
58 pub id: i64,
59 pub email: String,
60 pub role: Role,
61 pub is_active: bool,
62 pub created_at: DateTime<Utc>,
63 pub full_name: Option<String>,
64 pub locale: Option<String>,
65 pub timezone: Option<String>,
66 pub is_demo: bool,
67 pub demo_label: Option<String>,
68}
69
70pub async fn init_user_tables(db: &Db) -> Result<()> {
71 sqlx::query(
72 "CREATE TABLE IF NOT EXISTS rustio_users (
73 id BIGSERIAL PRIMARY KEY,
74 email TEXT NOT NULL UNIQUE,
75 password_hash TEXT NOT NULL,
76 role TEXT NOT NULL DEFAULT 'user',
77 is_active BOOLEAN NOT NULL DEFAULT TRUE,
78 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
79 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
80 )",
81 )
82 .execute(db.pool())
83 .await?;
84
85 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
86 .execute(db.pool())
87 .await?;
88
89 Ok(())
90}
91
92pub async fn migrate_user_schema(db: &Db) -> Result<()> {
105 sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
106 .execute(db.pool())
107 .await?;
108
109 sqlx::query(
110 "ALTER TABLE rustio_users \
111 ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
112 )
113 .execute(db.pool())
114 .await?;
115 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
116 .execute(db.pool())
117 .await?;
118
119 sqlx::query(
120 "DO $$
121 BEGIN
122 IF NOT EXISTS (
123 SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
124 ) THEN
125 ALTER TABLE rustio_users
126 ADD CONSTRAINT rustio_users_role_check
127 CHECK (role IN ('user','staff','supervisor','administrator','developer'));
128 END IF;
129 END $$",
130 )
131 .execute(db.pool())
132 .await?;
133
134 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
135 .execute(db.pool())
136 .await?;
137 sqlx::query(
138 "CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
139 ON rustio_users(is_demo) WHERE is_demo = TRUE",
140 )
141 .execute(db.pool())
142 .await?;
143
144 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
145 .execute(db.pool())
146 .await?;
147 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
148 .execute(db.pool())
149 .await?;
150 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
151 .execute(db.pool())
152 .await?;
153
154 Ok(())
155}
156
157pub fn hash_password(plain: &str) -> Result<String> {
158 let salt = SaltString::generate(&mut OsRng);
159 Argon2::default()
160 .hash_password(plain.as_bytes(), &salt)
161 .map(|h| h.to_string())
162 .map_err(|e| Error::Internal(format!("password hashing: {e}")))
163}
164
165pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
166 match PasswordHash::new(stored_hash) {
167 Ok(parsed) => Argon2::default()
168 .verify_password(plain.as_bytes(), &parsed)
169 .is_ok(),
170 Err(_) => false,
171 }
172}
173
174pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
175 let hash = hash_password(password)?;
176 let row = sqlx::query(
177 "INSERT INTO rustio_users (email, password_hash, role)
178 VALUES ($1, $2, $3)
179 RETURNING id",
180 )
181 .bind(email)
182 .bind(&hash)
183 .bind(role.as_str())
184 .fetch_one(db.pool())
185 .await
186 .map_err(|e| {
187 log::warn!("create_user failed for {email}: {e}");
192 let detail = e.to_string();
193 if detail.contains("rustio_users_email_key") {
194 Error::BadRequest("An account with this email already exists.".into())
195 } else {
196 Error::BadRequest("Could not create user. Please check your input.".into())
197 }
198 })?;
199 let id: i64 = row
200 .try_get("id")
201 .map_err(|e| Error::Internal(format!("returning id: {e}")))?;
202 Ok(id)
203}
204
205pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
206 let row = sqlx::query(
207 "SELECT id, email, password_hash, role, is_active, is_demo, demo_label
208 FROM rustio_users
209 WHERE email = $1",
210 )
211 .bind(email)
212 .fetch_optional(db.pool())
213 .await?;
214 match row {
215 Some(r) => {
216 let r = Row::from_pg(&r);
217 Ok(Some(StoredUser {
218 id: r.get_i64("id")?,
219 email: r.get_string("email")?,
220 password_hash: r.get_string("password_hash")?,
221 role: Role::parse(&r.get_string("role")?)?,
222 is_active: r.get_bool("is_active")?,
223 is_demo: r.get_bool("is_demo")?,
224 demo_label: r.get_optional_string("demo_label")?,
225 }))
226 }
227 None => Ok(None),
228 }
229}
230
231pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
235 let row = sqlx::query(
236 "SELECT id, email, role, is_active, created_at,
237 full_name, locale, timezone, is_demo, demo_label
238 FROM rustio_users
239 WHERE id = $1",
240 )
241 .bind(user_id)
242 .fetch_optional(db.pool())
243 .await?;
244 match row {
245 Some(r) => {
246 let r = Row::from_pg(&r);
247 Ok(Some(UserProfile {
248 id: r.get_i64("id")?,
249 email: r.get_string("email")?,
250 role: Role::parse(&r.get_string("role")?)?,
251 is_active: r.get_bool("is_active")?,
252 created_at: r.get_datetime("created_at")?,
253 full_name: r.get_optional_string("full_name")?,
254 locale: r.get_optional_string("locale")?,
255 timezone: r.get_optional_string("timezone")?,
256 is_demo: r.get_bool("is_demo")?,
257 demo_label: r.get_optional_string("demo_label")?,
258 }))
259 }
260 None => Ok(None),
261 }
262}
263
264pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
285 let hash = hash_password(new_password)?;
286 sqlx::query(
287 "UPDATE rustio_users \
288 SET password_hash = $1, password_changed_at = $2, updated_at = $2 \
289 WHERE id = $3",
290 )
291 .bind(&hash)
292 .bind(Utc::now())
293 .bind(user_id)
294 .execute(db.pool())
295 .await?;
296 Ok(())
297}
298
299pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
300 sqlx::query("UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3")
301 .bind(role.as_str())
302 .bind(Utc::now())
303 .bind(user_id)
304 .execute(db.pool())
305 .await?;
306 Ok(())
307}
308
309pub fn verdict_for_orphan_role(
318 active_count_in_protected: i64,
319 target_is_in_protected: bool,
320 new_role_is_protected: bool,
321 new_active: bool,
322) -> bool {
323 if !target_is_in_protected {
324 return false;
325 }
326 if active_count_in_protected != 1 {
327 return false;
328 }
329 !(new_active && new_role_is_protected)
332}
333
334pub async fn would_orphan_role(
347 db: &Db,
348 user_id: i64,
349 protected_role: Role,
350 new_role: Role,
351 new_active: bool,
352) -> Result<bool> {
353 let active_count: i64 = sqlx::query_scalar(
354 "SELECT COUNT(*) FROM rustio_users WHERE role = $1 AND is_active = TRUE",
355 )
356 .bind(protected_role.as_str())
357 .fetch_one(db.pool())
358 .await?;
359
360 let target_role_str: Option<String> =
361 sqlx::query_scalar("SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE")
362 .bind(user_id)
363 .fetch_optional(db.pool())
364 .await?;
365 let target_is_in_protected = target_role_str.as_deref() == Some(protected_role.as_str());
366
367 Ok(verdict_for_orphan_role(
368 active_count,
369 target_is_in_protected,
370 new_role == protected_role,
371 new_active,
372 ))
373}
374
375pub async fn would_orphan_protected(
379 db: &Db,
380 user_id: i64,
381 new_role: Role,
382 new_active: bool,
383) -> Result<Option<Role>> {
384 for &role in super::role::protected_roles() {
385 if would_orphan_role(db, user_id, role, new_role, new_active).await? {
386 return Ok(Some(role));
387 }
388 }
389 Ok(None)
390}
391
392#[deprecated(
396 since = "0.3.0",
397 note = "use `would_orphan_protected` to cover every protected role, not just Developer"
398)]
399pub async fn would_orphan_developers(
400 db: &Db,
401 user_id: i64,
402 new_role: Option<Role>,
403) -> Result<bool> {
404 let (role, active) = match new_role {
405 Some(r) => (r, true),
406 None => (Role::User, false),
407 };
408 would_orphan_role(db, user_id, Role::Developer, role, active).await
409}
410
411pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
415 let user = find_user_by_email(db, email)
416 .await?
417 .ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
418 if !user.is_active {
419 return Err(Error::Forbidden("account disabled".into()));
420 }
421 if !verify_password(password, &user.password_hash) {
422 return Err(Error::Unauthorized("invalid email or password".into()));
423 }
424 create_session(db, user.id).await
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn user_profile_derives_debug_and_clone() {
433 fn assert_traits<T: std::fmt::Debug + Clone>() {}
434 assert_traits::<UserProfile>();
435 }
436
437 #[test]
438 fn password_round_trip() {
439 let h = hash_password("secret").unwrap();
440 assert!(verify_password("secret", &h));
441 assert!(!verify_password("wrong", &h));
442 }
443
444 #[test]
447 fn verdict_safe_when_target_not_in_protected_pool() {
448 assert!(!verdict_for_orphan_role(0, false, false, true));
451 assert!(!verdict_for_orphan_role(1, false, false, false));
452 assert!(!verdict_for_orphan_role(5, false, true, true));
453 }
454
455 #[test]
456 fn verdict_safe_when_more_than_one_member() {
457 assert!(!verdict_for_orphan_role(2, true, false, true));
460 assert!(!verdict_for_orphan_role(5, true, false, false));
461 }
462
463 #[test]
464 fn verdict_blocks_when_last_member_demoting() {
465 assert!(verdict_for_orphan_role(1, true, false, true));
468 }
469
470 #[test]
471 fn verdict_blocks_when_last_member_deactivating() {
472 assert!(verdict_for_orphan_role(1, true, true, false));
474 }
475
476 #[test]
477 fn verdict_blocks_when_last_member_deleting() {
478 assert!(verdict_for_orphan_role(1, true, false, false));
480 }
481
482 #[test]
483 fn verdict_safe_when_last_member_keeps_role() {
484 assert!(!verdict_for_orphan_role(1, true, true, true));
486 }
487}