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}
40
41impl Identity {
42 pub fn is_admin(&self) -> bool {
44 self.is_active && self.role.includes(Role::Administrator)
45 }
46
47 pub fn can_access_admin(&self) -> bool {
49 self.is_active && self.role.can_access_panel()
50 }
51}
52
53pub struct StoredUser {
54 pub id: i64,
55 pub email: String,
56 pub password_hash: String,
57 pub role: Role,
58 pub is_active: bool,
59 pub is_demo: bool,
60 pub demo_label: Option<String>,
61 pub must_change_password: bool,
67}
68
69#[derive(Debug, Clone)]
73pub struct UserProfile {
74 pub id: i64,
75 pub email: String,
76 pub role: Role,
77 pub is_active: bool,
78 pub created_at: DateTime<Utc>,
79 pub full_name: Option<String>,
80 pub locale: Option<String>,
81 pub timezone: Option<String>,
82 pub is_demo: bool,
83 pub demo_label: Option<String>,
84}
85
86pub async fn init_user_tables(db: &Db) -> Result<()> {
87 sqlx::query(
88 "CREATE TABLE IF NOT EXISTS rustio_users (
89 id BIGSERIAL PRIMARY KEY,
90 email TEXT NOT NULL UNIQUE,
91 password_hash TEXT NOT NULL,
92 role TEXT NOT NULL DEFAULT 'user',
93 is_active BOOLEAN NOT NULL DEFAULT TRUE,
94 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
95 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
96 )",
97 )
98 .execute(db.pool())
99 .await?;
100
101 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_email_idx ON rustio_users (email)")
102 .execute(db.pool())
103 .await?;
104
105 Ok(())
106}
107
108pub async fn migrate_user_schema(db: &Db) -> Result<()> {
121 sqlx::query("UPDATE rustio_users SET role = 'administrator' WHERE role = 'admin'")
122 .execute(db.pool())
123 .await?;
124
125 sqlx::query(
126 "ALTER TABLE rustio_users \
127 ADD COLUMN IF NOT EXISTS is_demo BOOLEAN NOT NULL DEFAULT FALSE",
128 )
129 .execute(db.pool())
130 .await?;
131 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS demo_label TEXT")
132 .execute(db.pool())
133 .await?;
134
135 sqlx::query(
136 "DO $$
137 BEGIN
138 IF NOT EXISTS (
139 SELECT 1 FROM pg_constraint WHERE conname = 'rustio_users_role_check'
140 ) THEN
141 ALTER TABLE rustio_users
142 ADD CONSTRAINT rustio_users_role_check
143 CHECK (role IN ('user','staff','supervisor','administrator','developer'));
144 END IF;
145 END $$",
146 )
147 .execute(db.pool())
148 .await?;
149
150 sqlx::query("CREATE INDEX IF NOT EXISTS rustio_users_role_idx ON rustio_users(role)")
151 .execute(db.pool())
152 .await?;
153 sqlx::query(
154 "CREATE INDEX IF NOT EXISTS rustio_users_is_demo_idx \
155 ON rustio_users(is_demo) WHERE is_demo = TRUE",
156 )
157 .execute(db.pool())
158 .await?;
159
160 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS full_name TEXT")
161 .execute(db.pool())
162 .await?;
163 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS locale TEXT")
164 .execute(db.pool())
165 .await?;
166 sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS timezone TEXT")
167 .execute(db.pool())
168 .await?;
169
170 Ok(())
171}
172
173pub fn hash_password(plain: &str) -> Result<String> {
174 let salt = SaltString::generate(&mut OsRng);
175 Argon2::default()
176 .hash_password(plain.as_bytes(), &salt)
177 .map(|h| h.to_string())
178 .map_err(|e| Error::Internal(format!("password hashing: {e}")))
179}
180
181pub fn verify_password(plain: &str, stored_hash: &str) -> bool {
182 match PasswordHash::new(stored_hash) {
183 Ok(parsed) => Argon2::default()
184 .verify_password(plain.as_bytes(), &parsed)
185 .is_ok(),
186 Err(_) => false,
187 }
188}
189
190pub async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> Result<i64> {
191 let hash = hash_password(password)?;
192 let row = sqlx::query(
193 "INSERT INTO rustio_users (email, password_hash, role)
194 VALUES ($1, $2, $3)
195 RETURNING id",
196 )
197 .bind(email)
198 .bind(&hash)
199 .bind(role.as_str())
200 .fetch_one(db.pool())
201 .await
202 .map_err(|e| {
203 log::warn!("create_user failed for {email}: {e}");
208 let detail = e.to_string();
209 if detail.contains("rustio_users_email_key") {
210 Error::BadRequest("An account with this email already exists.".into())
211 } else {
212 Error::BadRequest("Could not create user. Please check your input.".into())
213 }
214 })?;
215 let id: i64 = row
216 .try_get("id")
217 .map_err(|e| Error::Internal(format!("returning id: {e}")))?;
218 Ok(id)
219}
220
221pub async fn find_user_by_email(db: &Db, email: &str) -> Result<Option<StoredUser>> {
222 let row = sqlx::query(
223 "SELECT id, email, password_hash, role, is_active, is_demo, demo_label, \
224 must_change_password \
225 FROM rustio_users \
226 WHERE email = $1",
227 )
228 .bind(email)
229 .fetch_optional(db.pool())
230 .await?;
231 match row {
232 Some(r) => {
233 let r = Row::from_pg(&r);
234 Ok(Some(StoredUser {
235 id: r.get_i64("id")?,
236 email: r.get_string("email")?,
237 password_hash: r.get_string("password_hash")?,
238 role: Role::parse(&r.get_string("role")?)?,
239 is_active: r.get_bool("is_active")?,
240 is_demo: r.get_bool("is_demo")?,
241 demo_label: r.get_optional_string("demo_label")?,
242 must_change_password: r.get_bool("must_change_password")?,
243 }))
244 }
245 None => Ok(None),
246 }
247}
248
249pub async fn load_user_profile(db: &Db, user_id: i64) -> Result<Option<UserProfile>> {
253 let row = sqlx::query(
254 "SELECT id, email, role, is_active, created_at,
255 full_name, locale, timezone, is_demo, demo_label
256 FROM rustio_users
257 WHERE id = $1",
258 )
259 .bind(user_id)
260 .fetch_optional(db.pool())
261 .await?;
262 match row {
263 Some(r) => {
264 let r = Row::from_pg(&r);
265 Ok(Some(UserProfile {
266 id: r.get_i64("id")?,
267 email: r.get_string("email")?,
268 role: Role::parse(&r.get_string("role")?)?,
269 is_active: r.get_bool("is_active")?,
270 created_at: r.get_datetime("created_at")?,
271 full_name: r.get_optional_string("full_name")?,
272 locale: r.get_optional_string("locale")?,
273 timezone: r.get_optional_string("timezone")?,
274 is_demo: r.get_bool("is_demo")?,
275 demo_label: r.get_optional_string("demo_label")?,
276 }))
277 }
278 None => Ok(None),
279 }
280}
281
282pub async fn set_password(db: &Db, user_id: i64, new_password: &str) -> Result<()> {
303 let hash = hash_password(new_password)?;
304 sqlx::query(
305 "UPDATE rustio_users \
306 SET password_hash = $1, password_changed_at = $2, updated_at = $2 \
307 WHERE id = $3",
308 )
309 .bind(&hash)
310 .bind(Utc::now())
311 .bind(user_id)
312 .execute(db.pool())
313 .await?;
314 Ok(())
315}
316
317pub async fn update_user_role(db: &Db, user_id: i64, role: Role) -> Result<()> {
318 sqlx::query("UPDATE rustio_users SET role = $1, updated_at = $2 WHERE id = $3")
319 .bind(role.as_str())
320 .bind(Utc::now())
321 .bind(user_id)
322 .execute(db.pool())
323 .await?;
324 Ok(())
325}
326
327pub fn verdict_for_orphan_role(
336 active_count_in_protected: i64,
337 target_is_in_protected: bool,
338 new_role_is_protected: bool,
339 new_active: bool,
340) -> bool {
341 if !target_is_in_protected {
342 return false;
343 }
344 if active_count_in_protected != 1 {
345 return false;
346 }
347 !(new_active && new_role_is_protected)
350}
351
352pub async fn would_orphan_role(
365 db: &Db,
366 user_id: i64,
367 protected_role: Role,
368 new_role: Role,
369 new_active: bool,
370) -> Result<bool> {
371 let active_count: i64 = sqlx::query_scalar(
372 "SELECT COUNT(*) FROM rustio_users WHERE role = $1 AND is_active = TRUE",
373 )
374 .bind(protected_role.as_str())
375 .fetch_one(db.pool())
376 .await?;
377
378 let target_role_str: Option<String> =
379 sqlx::query_scalar("SELECT role FROM rustio_users WHERE id = $1 AND is_active = TRUE")
380 .bind(user_id)
381 .fetch_optional(db.pool())
382 .await?;
383 let target_is_in_protected = target_role_str.as_deref() == Some(protected_role.as_str());
384
385 Ok(verdict_for_orphan_role(
386 active_count,
387 target_is_in_protected,
388 new_role == protected_role,
389 new_active,
390 ))
391}
392
393pub async fn would_orphan_protected(
397 db: &Db,
398 user_id: i64,
399 new_role: Role,
400 new_active: bool,
401) -> Result<Option<Role>> {
402 for &role in super::role::protected_roles() {
403 if would_orphan_role(db, user_id, role, new_role, new_active).await? {
404 return Ok(Some(role));
405 }
406 }
407 Ok(None)
408}
409
410#[deprecated(
414 since = "0.3.0",
415 note = "use `would_orphan_protected` to cover every protected role, not just Developer"
416)]
417pub async fn would_orphan_developers(
418 db: &Db,
419 user_id: i64,
420 new_role: Option<Role>,
421) -> Result<bool> {
422 let (role, active) = match new_role {
423 Some(r) => (r, true),
424 None => (Role::User, false),
425 };
426 would_orphan_role(db, user_id, Role::Developer, role, active).await
427}
428
429pub async fn login(db: &Db, email: &str, password: &str) -> Result<String> {
433 let user = find_user_by_email(db, email)
434 .await?
435 .ok_or_else(|| Error::Unauthorized("invalid email or password".into()))?;
436 if !user.is_active {
437 return Err(Error::Forbidden("account disabled".into()));
438 }
439 if !verify_password(password, &user.password_hash) {
440 return Err(Error::Unauthorized("invalid email or password".into()));
441 }
442 create_session(db, user.id).await
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn user_profile_derives_debug_and_clone() {
451 fn assert_traits<T: std::fmt::Debug + Clone>() {}
452 assert_traits::<UserProfile>();
453 }
454
455 #[test]
456 fn password_round_trip() {
457 let h = hash_password("secret").unwrap();
458 assert!(verify_password("secret", &h));
459 assert!(!verify_password("wrong", &h));
460 }
461
462 #[test]
465 fn verdict_safe_when_target_not_in_protected_pool() {
466 assert!(!verdict_for_orphan_role(0, false, false, true));
469 assert!(!verdict_for_orphan_role(1, false, false, false));
470 assert!(!verdict_for_orphan_role(5, false, true, true));
471 }
472
473 #[test]
474 fn verdict_safe_when_more_than_one_member() {
475 assert!(!verdict_for_orphan_role(2, true, false, true));
478 assert!(!verdict_for_orphan_role(5, true, false, false));
479 }
480
481 #[test]
482 fn verdict_blocks_when_last_member_demoting() {
483 assert!(verdict_for_orphan_role(1, true, false, true));
486 }
487
488 #[test]
489 fn verdict_blocks_when_last_member_deactivating() {
490 assert!(verdict_for_orphan_role(1, true, true, false));
492 }
493
494 #[test]
495 fn verdict_blocks_when_last_member_deleting() {
496 assert!(verdict_for_orphan_role(1, true, false, false));
498 }
499
500 #[test]
501 fn verdict_safe_when_last_member_keeps_role() {
502 assert!(!verdict_for_orphan_role(1, true, true, true));
504 }
505}