1use std::collections::HashMap;
19
20use chrono::{DateTime, SecondsFormat, Utc};
21use laterite_core::query::{
22 bind_values, bind_values_as, build, insert_returning_id, on_conflict_ignore,
23};
24use laterite_core::{AnyRowExt, Db};
25use sea_query::{Expr, Order, Query};
26use sqlx::any::AnyRow;
27use sqlx::Row;
28
29use crate::error::AuthError;
30use crate::models::{AccessEvent, BackendUser, BackendUserSummary};
31use crate::schema::{
32 BackendAccessLog, BackendAuditLog, BackendRememberTokens, BackendRoles, BackendSessions,
33 BackendUserPreferences, BackendUserRoles, BackendUsers,
34};
35
36fn now_ts() -> String {
37 Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true)
38}
39
40fn ts(dt: DateTime<Utc>) -> String {
41 dt.to_rfc3339_opts(SecondsFormat::Micros, true)
42}
43
44fn parse_ts(s: &str) -> Result<DateTime<Utc>, AuthError> {
45 DateTime::parse_from_rfc3339(s)
46 .map(|d| d.with_timezone(&Utc))
47 .map_err(|e| AuthError::Data(format!("timestamp `{s}`: {e}")))
48}
49
50fn normalize_key(s: &str) -> String {
57 s.trim().to_lowercase()
58}
59
60fn user_from_row(row: &AnyRow) -> Result<BackendUser, AuthError> {
61 Ok(BackendUser {
62 id: row.try_get::<i64, _>("id")?,
63 username: row.get_text("username")?,
64 email: row.get_text("email")?,
65 first_name: row.get_text("first_name")?,
66 last_name: row.get_text_opt("last_name")?,
67 password_hash: row.get_text("password_hash")?,
68 is_superuser: row.get_bool("is_superuser")?,
69 is_active: row.get_bool("is_active")?,
70 timezone: row.get_text_opt("timezone")?,
71 locale: row.get_text_opt("locale")?,
72 created_at: parse_ts(&row.get_text("created_at")?)?,
73 updated_at: parse_ts(&row.get_text("updated_at")?)?,
74 })
75}
76
77fn summary_from_row(row: &AnyRow) -> Result<BackendUserSummary, AuthError> {
78 Ok(BackendUserSummary {
79 id: row.try_get::<i64, _>("id")?,
80 username: row.get_text("username")?,
81 email: row.get_text("email")?,
82 first_name: row.get_text("first_name")?,
83 last_name: row.get_text_opt("last_name")?,
84 is_superuser: row.get_bool("is_superuser")?,
85 is_active: row.get_bool("is_active")?,
86 created_at: parse_ts(&row.get_text("created_at")?)?,
87 })
88}
89
90const USER_COLS: [BackendUsers; 12] = [
91 BackendUsers::Id,
92 BackendUsers::Username,
93 BackendUsers::Email,
94 BackendUsers::FirstName,
95 BackendUsers::LastName,
96 BackendUsers::PasswordHash,
97 BackendUsers::IsSuperuser,
98 BackendUsers::IsActive,
99 BackendUsers::Timezone,
100 BackendUsers::Locale,
101 BackendUsers::CreatedAt,
102 BackendUsers::UpdatedAt,
103];
104
105pub async fn find_user_by_username(
107 db: &Db,
108 username: &str,
109) -> Result<Option<BackendUser>, AuthError> {
110 let (sql, values) = build(
111 db.backend,
112 Query::select()
113 .columns(USER_COLS)
114 .from(BackendUsers::Table)
115 .and_where(Expr::col(BackendUsers::Username).eq(normalize_key(username)))
116 .to_owned(),
117 );
118 let row = bind_values(sqlx::query(&sql), values)
119 .fetch_optional(&db.pool)
120 .await?;
121 row.map(|r| user_from_row(&r)).transpose()
122}
123
124pub(crate) async fn find_user_by_id(db: &Db, id: i64) -> Result<Option<BackendUser>, AuthError> {
129 let (sql, values) = build(
130 db.backend,
131 Query::select()
132 .columns(USER_COLS)
133 .from(BackendUsers::Table)
134 .and_where(Expr::col(BackendUsers::Id).eq(id))
135 .to_owned(),
136 );
137 let row = bind_values(sqlx::query(&sql), values)
138 .fetch_optional(&db.pool)
139 .await?;
140 row.map(|r| user_from_row(&r)).transpose()
141}
142
143pub async fn find_active_user_by_id(db: &Db, id: i64) -> Result<Option<BackendUser>, AuthError> {
144 let (sql, values) = build(
145 db.backend,
146 Query::select()
147 .columns(USER_COLS)
148 .from(BackendUsers::Table)
149 .and_where(Expr::col(BackendUsers::Id).eq(id))
150 .and_where(Expr::col(BackendUsers::IsActive).eq(true))
151 .to_owned(),
152 );
153 let row = bind_values(sqlx::query(&sql), values)
154 .fetch_optional(&db.pool)
155 .await?;
156 row.map(|r| user_from_row(&r)).transpose()
157}
158
159pub async fn load_role_permissions(db: &Db, user_id: i64) -> Result<Vec<Vec<String>>, AuthError> {
162 let (sql, values) = build(
163 db.backend,
164 Query::select()
165 .column((BackendRoles::Table, BackendRoles::Permissions))
166 .from(BackendUserRoles::Table)
167 .inner_join(
168 BackendRoles::Table,
169 Expr::col((BackendRoles::Table, BackendRoles::Id))
170 .equals((BackendUserRoles::Table, BackendUserRoles::BackendRoleId)),
171 )
172 .and_where(
173 Expr::col((BackendUserRoles::Table, BackendUserRoles::BackendUserId)).eq(user_id),
174 )
175 .to_owned(),
176 );
177 let rows = bind_values(sqlx::query(&sql), values)
178 .fetch_all(&db.pool)
179 .await?;
180 let mut out = Vec::with_capacity(rows.len());
181 for row in rows {
182 let json = row.get_text("permissions")?;
183 let perms: Vec<String> = serde_json::from_str(&json)
184 .map_err(|e| AuthError::Data(format!("role permissions: {e}")))?;
185 out.push(perms);
186 }
187 Ok(out)
188}
189
190pub async fn load_user_permission_overrides(
193 db: &Db,
194 user_id: i64,
195) -> Result<HashMap<String, i64>, AuthError> {
196 let (sql, values) = build(
197 db.backend,
198 Query::select()
199 .column(BackendUsers::Permissions)
200 .from(BackendUsers::Table)
201 .and_where(Expr::col(BackendUsers::Id).eq(user_id))
202 .to_owned(),
203 );
204 let row = bind_values(sqlx::query(&sql), values)
205 .fetch_optional(&db.pool)
206 .await?;
207 let overrides = match row {
208 Some(r) => {
209 let json = r.get_text("permissions")?;
210 serde_json::from_str(&json).unwrap_or_default()
211 }
212 None => HashMap::new(),
213 };
214 Ok(overrides)
215}
216
217pub async fn set_user_permissions(
219 db: &Db,
220 user_id: i64,
221 overrides: &HashMap<String, i64>,
222) -> Result<(), AuthError> {
223 let json = serde_json::to_string(overrides).unwrap_or_else(|_| "{}".to_string());
224 let (sql, values) = build(
225 db.backend,
226 Query::update()
227 .table(BackendUsers::Table)
228 .value(BackendUsers::Permissions, json)
229 .and_where(Expr::col(BackendUsers::Id).eq(user_id))
230 .to_owned(),
231 );
232 bind_values(sqlx::query(&sql), values)
233 .execute(&db.pool)
234 .await?;
235 Ok(())
236}
237
238pub(crate) async fn insert_session(
239 db: &Db,
240 token_hash: &str,
241 user_id: i64,
242 expires_at: DateTime<Utc>,
243 ip_address: Option<&str>,
244 user_agent: Option<&str>,
245) -> Result<(), AuthError> {
246 let now = now_ts();
247 let (sql, values) = build(
248 db.backend,
249 Query::insert()
250 .into_table(BackendSessions::Table)
251 .columns([
252 BackendSessions::TokenHash,
253 BackendSessions::BackendUserId,
254 BackendSessions::CreatedAt,
255 BackendSessions::LastSeenAt,
256 BackendSessions::ExpiresAt,
257 BackendSessions::IpAddress,
258 BackendSessions::UserAgent,
259 ])
260 .values_panic([
261 token_hash.into(),
262 user_id.into(),
263 now.clone().into(),
264 now.into(),
265 ts(expires_at).into(),
266 ip_address.into(),
267 user_agent.into(),
268 ])
269 .to_owned(),
270 );
271 bind_values(sqlx::query(&sql), values)
272 .execute(&db.pool)
273 .await?;
274 Ok(())
275}
276
277pub struct ValidSession {
279 pub user_id: i64,
280 pub data: Option<String>,
281 pub created_at: DateTime<Utc>,
283 pub last_seen_at: DateTime<Utc>,
285}
286
287pub(crate) async fn find_valid_session(
291 db: &Db,
292 token_hash: &str,
293 now: DateTime<Utc>,
294) -> Result<Option<ValidSession>, AuthError> {
295 let (sql, values) = build(
296 db.backend,
297 Query::select()
298 .columns([
299 BackendSessions::BackendUserId,
300 BackendSessions::Data,
301 BackendSessions::CreatedAt,
302 BackendSessions::LastSeenAt,
303 ])
304 .from(BackendSessions::Table)
305 .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
306 .and_where(Expr::col(BackendSessions::ExpiresAt).gt(ts(now)))
307 .and_where(Expr::col(BackendSessions::RevokedReason).is_null())
308 .to_owned(),
309 );
310 let row = bind_values(sqlx::query(&sql), values)
311 .fetch_optional(&db.pool)
312 .await?;
313 match row {
314 Some(r) => Ok(Some(ValidSession {
315 user_id: r.try_get::<i64, _>("backend_user_id")?,
316 data: r.get_text_opt("data")?,
317 created_at: parse_ts(&r.get_text("created_at")?)?,
318 last_seen_at: parse_ts(&r.get_text("last_seen_at")?)?,
319 })),
320 None => Ok(None),
321 }
322}
323
324pub(crate) async fn set_session_data(
327 db: &Db,
328 token_hash: &str,
329 data: &str,
330) -> Result<(), AuthError> {
331 let (sql, values) = build(
332 db.backend,
333 Query::update()
334 .table(BackendSessions::Table)
335 .value(BackendSessions::Data, data)
336 .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
337 .to_owned(),
338 );
339 bind_values(sqlx::query(&sql), values)
340 .execute(&db.pool)
341 .await?;
342 Ok(())
343}
344
345pub(crate) async fn renew_session(
349 db: &Db,
350 token_hash: &str,
351 now: DateTime<Utc>,
352 expires_at: DateTime<Utc>,
353) -> Result<(), AuthError> {
354 let (sql, values) = build(
355 db.backend,
356 Query::update()
357 .table(BackendSessions::Table)
358 .value(BackendSessions::LastSeenAt, ts(now))
359 .value(BackendSessions::ExpiresAt, ts(expires_at))
360 .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
361 .to_owned(),
362 );
363 bind_values(sqlx::query(&sql), values)
364 .execute(&db.pool)
365 .await?;
366 Ok(())
367}
368
369pub(crate) struct RememberToken {
373 pub user_id: i64,
374 pub verifier_hash: String,
375}
376
377pub(crate) async fn insert_remember_token(
378 db: &Db,
379 selector: &str,
380 verifier_hash: &str,
381 user_id: i64,
382 expires_at: DateTime<Utc>,
383) -> Result<(), AuthError> {
384 let (sql, values) = build(
385 db.backend,
386 Query::insert()
387 .into_table(BackendRememberTokens::Table)
388 .columns([
389 BackendRememberTokens::Selector,
390 BackendRememberTokens::VerifierHash,
391 BackendRememberTokens::BackendUserId,
392 BackendRememberTokens::CreatedAt,
393 BackendRememberTokens::ExpiresAt,
394 ])
395 .values_panic([
396 selector.into(),
397 verifier_hash.into(),
398 user_id.into(),
399 now_ts().into(),
400 ts(expires_at).into(),
401 ])
402 .to_owned(),
403 );
404 bind_values(sqlx::query(&sql), values)
405 .execute(&db.pool)
406 .await?;
407 Ok(())
408}
409
410pub(crate) async fn find_remember_token(
411 db: &Db,
412 selector: &str,
413 now: DateTime<Utc>,
414) -> Result<Option<RememberToken>, AuthError> {
415 let (sql, values) = build(
416 db.backend,
417 Query::select()
418 .columns([
419 BackendRememberTokens::BackendUserId,
420 BackendRememberTokens::VerifierHash,
421 ])
422 .from(BackendRememberTokens::Table)
423 .and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
424 .and_where(Expr::col(BackendRememberTokens::ExpiresAt).gt(ts(now)))
425 .and_where(Expr::col(BackendRememberTokens::RevokedReason).is_null())
426 .to_owned(),
427 );
428 let row = bind_values(sqlx::query(&sql), values)
429 .fetch_optional(&db.pool)
430 .await?;
431 match row {
432 Some(r) => Ok(Some(RememberToken {
433 user_id: r.try_get::<i64, _>("backend_user_id")?,
434 verifier_hash: r.get_text("verifier_hash")?,
435 })),
436 None => Ok(None),
437 }
438}
439
440pub(crate) async fn rotate_remember_token(
444 db: &Db,
445 selector: &str,
446 verifier_hash: &str,
447 expires_at: DateTime<Utc>,
448) -> Result<(), AuthError> {
449 let (sql, values) = build(
450 db.backend,
451 Query::update()
452 .table(BackendRememberTokens::Table)
453 .value(BackendRememberTokens::VerifierHash, verifier_hash)
454 .value(BackendRememberTokens::ExpiresAt, ts(expires_at))
455 .and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
456 .to_owned(),
457 );
458 bind_values(sqlx::query(&sql), values)
459 .execute(&db.pool)
460 .await?;
461 Ok(())
462}
463
464pub(crate) async fn delete_remember_token(db: &Db, selector: &str) -> Result<(), AuthError> {
465 let (sql, values) = build(
466 db.backend,
467 Query::delete()
468 .from_table(BackendRememberTokens::Table)
469 .and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
470 .to_owned(),
471 );
472 bind_values(sqlx::query(&sql), values)
473 .execute(&db.pool)
474 .await?;
475 Ok(())
476}
477
478pub(crate) async fn revoke_user_remember_tokens(
480 db: &Db,
481 user_id: i64,
482 reason: RevokeReason,
483) -> Result<(), AuthError> {
484 let (sql, values) = revoke_user_remember_tokens_query(db.backend, user_id, reason);
485 bind_values(sqlx::query(&sql), values)
486 .execute(&db.pool)
487 .await?;
488 Ok(())
489}
490
491fn revoke_user_remember_tokens_query(
492 backend: laterite_core::DbBackend,
493 user_id: i64,
494 reason: RevokeReason,
495) -> (String, sea_query::Values) {
496 build(
497 backend,
498 Query::update()
499 .table(BackendRememberTokens::Table)
500 .value(BackendRememberTokens::RevokedReason, reason.as_str())
501 .and_where(Expr::col(BackendRememberTokens::BackendUserId).eq(user_id))
502 .and_where(Expr::col(BackendRememberTokens::RevokedReason).is_null())
503 .to_owned(),
504 )
505}
506
507pub(crate) async fn revoked_remember_token(
509 db: &Db,
510 selector: &str,
511) -> Result<Option<(String, RevokeReason)>, AuthError> {
512 let (sql, values) = build(
513 db.backend,
514 Query::select()
515 .columns([
516 BackendRememberTokens::VerifierHash,
517 BackendRememberTokens::RevokedReason,
518 ])
519 .from(BackendRememberTokens::Table)
520 .and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
521 .and_where(Expr::col(BackendRememberTokens::RevokedReason).is_not_null())
522 .to_owned(),
523 );
524 let Some(row) = bind_values(sqlx::query(&sql), values)
525 .fetch_optional(&db.pool)
526 .await?
527 else {
528 return Ok(None);
529 };
530 let reason = row
531 .get_text_opt("revoked_reason")?
532 .as_deref()
533 .and_then(RevokeReason::parse);
534 Ok(reason.map(|r| (row.get_text("verifier_hash").unwrap_or_default(), r)))
535}
536
537pub(crate) async fn delete_user_remember_tokens(db: &Db, user_id: i64) -> Result<(), AuthError> {
541 let (sql, values) = build(
542 db.backend,
543 Query::delete()
544 .from_table(BackendRememberTokens::Table)
545 .and_where(Expr::col(BackendRememberTokens::BackendUserId).eq(user_id))
546 .to_owned(),
547 );
548 bind_values(sqlx::query(&sql), values)
549 .execute(&db.pool)
550 .await?;
551 Ok(())
552}
553
554pub(crate) struct SessionSummary {
556 pub token_hash: String,
557 pub created_at: DateTime<Utc>,
558 pub last_seen_at: DateTime<Utc>,
559 pub expires_at: DateTime<Utc>,
560 pub ip_address: Option<String>,
561 pub user_agent: Option<String>,
562}
563
564pub(crate) async fn list_user_sessions(
565 db: &Db,
566 user_id: i64,
567 now: DateTime<Utc>,
568) -> Result<Vec<SessionSummary>, AuthError> {
569 let (sql, values) = build(
570 db.backend,
571 Query::select()
572 .columns([
573 BackendSessions::TokenHash,
574 BackendSessions::CreatedAt,
575 BackendSessions::LastSeenAt,
576 BackendSessions::ExpiresAt,
577 BackendSessions::IpAddress,
578 BackendSessions::UserAgent,
579 ])
580 .from(BackendSessions::Table)
581 .and_where(Expr::col(BackendSessions::BackendUserId).eq(user_id))
582 .and_where(Expr::col(BackendSessions::ExpiresAt).gt(ts(now)))
583 .and_where(Expr::col(BackendSessions::RevokedReason).is_null())
584 .order_by(BackendSessions::LastSeenAt, Order::Desc)
585 .to_owned(),
586 );
587 let rows = bind_values(sqlx::query(&sql), values)
588 .fetch_all(&db.pool)
589 .await?;
590 rows.iter()
591 .map(|r| {
592 Ok(SessionSummary {
593 token_hash: r.get_text("token_hash")?,
594 created_at: parse_ts(&r.get_text("created_at")?)?,
595 last_seen_at: parse_ts(&r.get_text("last_seen_at")?)?,
596 expires_at: parse_ts(&r.get_text("expires_at")?)?,
597 ip_address: r.get_text_opt("ip_address")?,
598 user_agent: r.get_text_opt("user_agent")?,
599 })
600 })
601 .collect()
602}
603
604pub(crate) async fn revoke_user_session(
608 db: &Db,
609 user_id: i64,
610 token_hash: &str,
611 reason: RevokeReason,
612) -> Result<u64, AuthError> {
613 let (sql, values) = build(
614 db.backend,
615 Query::update()
616 .table(BackendSessions::Table)
617 .value(BackendSessions::RevokedReason, reason.as_str())
618 .and_where(Expr::col(BackendSessions::BackendUserId).eq(user_id))
619 .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
620 .and_where(Expr::col(BackendSessions::RevokedReason).is_null())
621 .to_owned(),
622 );
623 let done = bind_values(sqlx::query(&sql), values)
624 .execute(&db.pool)
625 .await?;
626 Ok(done.rows_affected())
627}
628
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633#[non_exhaustive]
634pub enum RevokeReason {
635 PasswordChanged,
637 Deactivated,
639 SignedOutElsewhere,
641}
642
643impl RevokeReason {
644 pub fn as_str(self) -> &'static str {
645 match self {
646 RevokeReason::PasswordChanged => "password_changed",
647 RevokeReason::Deactivated => "deactivated",
648 RevokeReason::SignedOutElsewhere => "signed_out_elsewhere",
649 }
650 }
651
652 pub fn parse(code: &str) -> Option<Self> {
653 match code {
654 "password_changed" => Some(RevokeReason::PasswordChanged),
655 "deactivated" => Some(RevokeReason::Deactivated),
656 "signed_out_elsewhere" => Some(RevokeReason::SignedOutElsewhere),
657 _ => None,
658 }
659 }
660}
661
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664#[non_exhaustive]
665pub enum SessionEnd {
666 Expired,
668 Revoked(RevokeReason),
670}
671
672impl SessionEnd {
673 pub fn code(self) -> &'static str {
675 match self {
676 SessionEnd::Expired => "expired",
677 SessionEnd::Revoked(reason) => reason.as_str(),
678 }
679 }
680}
681
682pub(crate) async fn revoke_user_sessions(
687 db: &Db,
688 user_id: i64,
689 keep: Option<&str>,
690 reason: RevokeReason,
691) -> Result<u64, AuthError> {
692 let (sql, values) = revoke_user_sessions_query(db.backend, user_id, keep, reason);
693 let done = bind_values(sqlx::query(&sql), values)
694 .execute(&db.pool)
695 .await?;
696 Ok(done.rows_affected())
697}
698
699fn revoke_user_sessions_query(
700 backend: laterite_core::DbBackend,
701 user_id: i64,
702 keep: Option<&str>,
703 reason: RevokeReason,
704) -> (String, sea_query::Values) {
705 let mut update = Query::update();
706 update
707 .table(BackendSessions::Table)
708 .value(BackendSessions::RevokedReason, reason.as_str())
709 .and_where(Expr::col(BackendSessions::BackendUserId).eq(user_id))
710 .and_where(Expr::col(BackendSessions::RevokedReason).is_null());
711 if let Some(keep) = keep {
712 update.and_where(Expr::col(BackendSessions::TokenHash).ne(keep));
713 }
714 build(backend, update.to_owned())
715}
716
717pub(crate) async fn session_end(
719 db: &Db,
720 token_hash: &str,
721 now: DateTime<Utc>,
722) -> Result<Option<SessionEnd>, AuthError> {
723 let (sql, values) = build(
724 db.backend,
725 Query::select()
726 .columns([BackendSessions::RevokedReason, BackendSessions::ExpiresAt])
727 .from(BackendSessions::Table)
728 .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
729 .to_owned(),
730 );
731 let Some(row) = bind_values(sqlx::query(&sql), values)
732 .fetch_optional(&db.pool)
733 .await?
734 else {
735 return Ok(None);
736 };
737 if let Some(reason) = row
738 .get_text_opt("revoked_reason")?
739 .as_deref()
740 .and_then(RevokeReason::parse)
741 {
742 return Ok(Some(SessionEnd::Revoked(reason)));
743 }
744 let expires = parse_ts(&row.get_text("expires_at")?)?;
745 Ok((expires <= now).then_some(SessionEnd::Expired))
746}
747
748pub(crate) async fn change_password(
754 db: &Db,
755 user_id: i64,
756 password_hash: &str,
757 keep: Option<&str>,
758 actor_user_id: Option<i64>,
759 actor_username: &str,
760) -> Result<u64, AuthError> {
761 let mut tx = db.pool.begin().await?;
762 let (sql, values) = build(
763 db.backend,
764 Query::update()
765 .table(BackendUsers::Table)
766 .value(BackendUsers::PasswordHash, password_hash)
767 .value(BackendUsers::PasswordChangedAt, now_ts())
768 .value(BackendUsers::UpdatedAt, now_ts())
769 .and_where(Expr::col(BackendUsers::Id).eq(user_id))
770 .to_owned(),
771 );
772 let done = bind_values(sqlx::query(&sql), values)
773 .execute(&mut *tx)
774 .await?
775 .rows_affected();
776 if done == 0 {
777 return Ok(0);
778 }
779 let (sql, values) =
780 revoke_user_sessions_query(db.backend, user_id, keep, RevokeReason::PasswordChanged);
781 bind_values(sqlx::query(&sql), values)
782 .execute(&mut *tx)
783 .await?;
784 let (sql, values) =
785 revoke_user_remember_tokens_query(db.backend, user_id, RevokeReason::PasswordChanged);
786 bind_values(sqlx::query(&sql), values)
787 .execute(&mut *tx)
788 .await?;
789 let target = user_id.to_string();
790 let (sql, values) = insert_audit_log_query(
791 db.backend,
792 actor_user_id,
793 actor_username,
794 "backend.user.password_change",
795 Some("backend_user"),
796 Some(&target),
797 None,
798 );
799 bind_values(sqlx::query(&sql), values)
800 .execute(&mut *tx)
801 .await?;
802 tx.commit().await?;
803 Ok(done)
804}
805
806pub(crate) async fn password_changed_at(
809 db: &Db,
810 user_id: i64,
811) -> Result<Option<DateTime<Utc>>, AuthError> {
812 let (sql, values) = build(
813 db.backend,
814 Query::select()
815 .column(BackendUsers::PasswordChangedAt)
816 .from(BackendUsers::Table)
817 .and_where(Expr::col(BackendUsers::Id).eq(user_id))
818 .to_owned(),
819 );
820 let row = bind_values(sqlx::query(&sql), values)
821 .fetch_optional(&db.pool)
822 .await?;
823 match row {
824 Some(row) => row
825 .get_text_opt("password_changed_at")?
826 .map(|s| parse_ts(&s))
827 .transpose(),
828 None => Ok(None),
829 }
830}
831
832pub(crate) async fn delete_session(db: &Db, token_hash: &str) -> Result<(), AuthError> {
833 let (sql, values) = build(
834 db.backend,
835 Query::delete()
836 .from_table(BackendSessions::Table)
837 .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
838 .to_owned(),
839 );
840 bind_values(sqlx::query(&sql), values)
841 .execute(&db.pool)
842 .await?;
843 Ok(())
844}
845
846pub async fn insert_access_log(
847 db: &Db,
848 user_id: Option<i64>,
849 username_attempted: &str,
850 event: AccessEvent,
851 ip_address: Option<&str>,
852 user_agent: Option<&str>,
853) -> Result<(), AuthError> {
854 let (sql, values) = build(
855 db.backend,
856 Query::insert()
857 .into_table(BackendAccessLog::Table)
858 .columns([
859 BackendAccessLog::BackendUserId,
860 BackendAccessLog::UsernameAttempted,
861 BackendAccessLog::Event,
862 BackendAccessLog::IpAddress,
863 BackendAccessLog::UserAgent,
864 BackendAccessLog::CreatedAt,
865 ])
866 .values_panic([
867 user_id.into(),
868 normalize_key(username_attempted).into(),
869 event.as_str().into(),
870 ip_address.map(str::to_string).into(),
871 user_agent.map(str::to_string).into(),
872 now_ts().into(),
873 ])
874 .to_owned(),
875 );
876 bind_values(sqlx::query(&sql), values)
877 .execute(&db.pool)
878 .await?;
879 Ok(())
880}
881
882#[derive(Debug, Clone)]
884pub struct AuditRecord {
885 pub actor_user_id: Option<i64>,
886 pub actor_username: String,
887 pub action: String,
888 pub target_type: Option<String>,
889 pub target_id: Option<String>,
890 pub detail: Option<String>,
891 pub created_at: DateTime<Utc>,
892}
893
894#[allow(clippy::too_many_arguments)]
896pub async fn insert_audit_log(
897 db: &Db,
898 actor_user_id: Option<i64>,
899 actor_username: &str,
900 action: &str,
901 target_type: Option<&str>,
902 target_id: Option<&str>,
903 detail: Option<&str>,
904) -> Result<(), AuthError> {
905 let (sql, values) = insert_audit_log_query(
906 db.backend,
907 actor_user_id,
908 actor_username,
909 action,
910 target_type,
911 target_id,
912 detail,
913 );
914 bind_values(sqlx::query(&sql), values)
915 .execute(&db.pool)
916 .await?;
917 Ok(())
918}
919
920#[allow(clippy::too_many_arguments)]
921fn insert_audit_log_query(
922 backend: laterite_core::DbBackend,
923 actor_user_id: Option<i64>,
924 actor_username: &str,
925 action: &str,
926 target_type: Option<&str>,
927 target_id: Option<&str>,
928 detail: Option<&str>,
929) -> (String, sea_query::Values) {
930 build(
931 backend,
932 Query::insert()
933 .into_table(BackendAuditLog::Table)
934 .columns([
935 BackendAuditLog::ActorUserId,
936 BackendAuditLog::ActorUsername,
937 BackendAuditLog::Action,
938 BackendAuditLog::TargetType,
939 BackendAuditLog::TargetId,
940 BackendAuditLog::Detail,
941 BackendAuditLog::CreatedAt,
942 ])
943 .values_panic([
944 actor_user_id.into(),
945 actor_username.to_string().into(),
946 action.to_string().into(),
947 target_type.map(str::to_string).into(),
948 target_id.map(str::to_string).into(),
949 detail.map(str::to_string).into(),
950 now_ts().into(),
951 ])
952 .to_owned(),
953 )
954}
955
956pub async fn recent_audit(db: &Db, limit: u64) -> Result<Vec<AuditRecord>, AuthError> {
959 let (sql, values) = build(
960 db.backend,
961 Query::select()
962 .columns([
963 BackendAuditLog::ActorUserId,
964 BackendAuditLog::ActorUsername,
965 BackendAuditLog::Action,
966 BackendAuditLog::TargetType,
967 BackendAuditLog::TargetId,
968 BackendAuditLog::Detail,
969 BackendAuditLog::CreatedAt,
970 ])
971 .from(BackendAuditLog::Table)
972 .order_by(BackendAuditLog::Id, Order::Desc)
973 .limit(limit)
974 .to_owned(),
975 );
976 let rows = bind_values(sqlx::query(&sql), values)
977 .fetch_all(&db.pool)
978 .await?;
979 rows.iter().map(audit_from_row).collect()
980}
981
982fn audit_from_row(row: &AnyRow) -> Result<AuditRecord, AuthError> {
983 Ok(AuditRecord {
984 actor_user_id: row.get_int_opt("actor_user_id")?,
985 actor_username: row.get_text("actor_username")?,
986 action: row.get_text("action")?,
987 target_type: row.get_text_opt("target_type")?,
988 target_id: row.get_text_opt("target_id")?,
989 detail: row.get_text_opt("detail")?,
990 created_at: parse_ts(&row.get_text("created_at")?)?,
991 })
992}
993
994pub async fn count_recent_failures(
996 db: &Db,
997 username: &str,
998 since: DateTime<Utc>,
999) -> Result<i64, AuthError> {
1000 let (sql, values) = build(
1001 db.backend,
1002 Query::select()
1003 .expr(Expr::col(BackendAccessLog::Id).count())
1004 .from(BackendAccessLog::Table)
1005 .and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
1006 .and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
1007 .and_where(Expr::col(BackendAccessLog::CreatedAt).gte(ts(since)))
1008 .to_owned(),
1009 );
1010 let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
1011 .fetch_one(&db.pool)
1012 .await?
1013 .0;
1014 Ok(count)
1015}
1016
1017#[allow(clippy::too_many_arguments)]
1020pub async fn create_user(
1021 db: &Db,
1022 username: &str,
1023 email: &str,
1024 first_name: &str,
1025 last_name: Option<&str>,
1026 password_hash: &str,
1027 is_superuser: bool,
1028) -> Result<i64, AuthError> {
1029 let now = now_ts();
1030 let stmt = Query::insert()
1031 .into_table(BackendUsers::Table)
1032 .columns([
1033 BackendUsers::Username,
1034 BackendUsers::Email,
1035 BackendUsers::FirstName,
1036 BackendUsers::LastName,
1037 BackendUsers::PasswordHash,
1038 BackendUsers::IsSuperuser,
1039 BackendUsers::PasswordChangedAt,
1040 BackendUsers::CreatedAt,
1041 BackendUsers::UpdatedAt,
1042 ])
1043 .values_panic([
1044 normalize_key(username).into(),
1045 normalize_key(email).into(),
1046 first_name.into(),
1047 last_name.map(str::to_string).into(),
1048 password_hash.into(),
1049 is_superuser.into(),
1050 now.clone().into(),
1051 now.clone().into(),
1052 now.into(),
1053 ])
1054 .to_owned();
1055 Ok(insert_returning_id(db, stmt, BackendUsers::Id).await?)
1056}
1057
1058pub async fn any_user_exists(db: &Db) -> Result<bool, AuthError> {
1060 let (sql, values) = build(
1061 db.backend,
1062 Query::select()
1063 .expr(Expr::col(BackendUsers::Id).count())
1064 .from(BackendUsers::Table)
1065 .to_owned(),
1066 );
1067 let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
1068 .fetch_one(&db.pool)
1069 .await?
1070 .0;
1071 Ok(count > 0)
1072}
1073
1074pub async fn set_user_timezone(
1076 db: &Db,
1077 user_id: i64,
1078 timezone: Option<&str>,
1079) -> Result<(), AuthError> {
1080 let (sql, values) = build(
1081 db.backend,
1082 Query::update()
1083 .table(BackendUsers::Table)
1084 .value(BackendUsers::Timezone, timezone.map(str::to_string))
1085 .value(BackendUsers::UpdatedAt, now_ts())
1086 .and_where(Expr::col(BackendUsers::Id).eq(user_id))
1087 .to_owned(),
1088 );
1089 bind_values(sqlx::query(&sql), values)
1090 .execute(&db.pool)
1091 .await?;
1092 Ok(())
1093}
1094
1095pub async fn set_user_locale(db: &Db, user_id: i64, locale: Option<&str>) -> Result<(), AuthError> {
1097 let (sql, values) = build(
1098 db.backend,
1099 Query::update()
1100 .table(BackendUsers::Table)
1101 .value(BackendUsers::Locale, locale.map(str::to_string))
1102 .value(BackendUsers::UpdatedAt, now_ts())
1103 .and_where(Expr::col(BackendUsers::Id).eq(user_id))
1104 .to_owned(),
1105 );
1106 bind_values(sqlx::query(&sql), values)
1107 .execute(&db.pool)
1108 .await?;
1109 Ok(())
1110}
1111
1112pub async fn create_role(
1113 db: &Db,
1114 code: &str,
1115 name: &str,
1116 permissions: &[String],
1117) -> Result<i64, AuthError> {
1118 let perms = serde_json::to_string(permissions).unwrap_or_else(|_| "[]".to_string());
1119 let stmt = Query::insert()
1120 .into_table(BackendRoles::Table)
1121 .columns([
1122 BackendRoles::Code,
1123 BackendRoles::Name,
1124 BackendRoles::Permissions,
1125 BackendRoles::CreatedAt,
1126 ])
1127 .values_panic([code.into(), name.into(), perms.into(), now_ts().into()])
1128 .to_owned();
1129 Ok(insert_returning_id(db, stmt, BackendRoles::Id).await?)
1130}
1131
1132pub struct SystemRole<'a> {
1135 pub code: &'a str,
1136 pub name: &'a str,
1137 pub description: &'a str,
1138 pub permissions: Vec<String>,
1139}
1140
1141pub async fn sync_system_roles(db: &Db, roles: &[SystemRole<'_>]) -> Result<(), AuthError> {
1148 for role in roles {
1149 let perms = serde_json::to_string(&role.permissions).unwrap_or_else(|_| "[]".to_string());
1150 let (sql, values) = build(
1154 db.backend,
1155 Query::insert()
1156 .into_table(BackendRoles::Table)
1157 .columns([
1158 BackendRoles::Code,
1159 BackendRoles::Name,
1160 BackendRoles::Description,
1161 BackendRoles::Permissions,
1162 BackendRoles::IsSystem,
1163 BackendRoles::CreatedAt,
1164 ])
1165 .values_panic([
1166 role.code.into(),
1167 role.name.into(),
1168 role.description.into(),
1169 perms.clone().into(),
1170 true.into(),
1171 now_ts().into(),
1172 ])
1173 .on_conflict(on_conflict_ignore([BackendRoles::Code]))
1174 .to_owned(),
1175 );
1176 bind_values(sqlx::query(&sql), values)
1177 .execute(&db.pool)
1178 .await?;
1179 let (sql, values) = build(
1180 db.backend,
1181 Query::update()
1182 .table(BackendRoles::Table)
1183 .values([
1184 (BackendRoles::Name, role.name.into()),
1185 (BackendRoles::Description, role.description.into()),
1186 (BackendRoles::Permissions, perms.into()),
1187 (BackendRoles::IsSystem, true.into()),
1188 ])
1189 .and_where(Expr::col(BackendRoles::Code).eq(role.code))
1190 .to_owned(),
1191 );
1192 bind_values(sqlx::query(&sql), values)
1193 .execute(&db.pool)
1194 .await?;
1195 }
1196 Ok(())
1197}
1198
1199pub async fn role_id_by_code(db: &Db, code: &str) -> Result<Option<i64>, AuthError> {
1201 let (sql, values) = build(
1202 db.backend,
1203 Query::select()
1204 .column(BackendRoles::Id)
1205 .from(BackendRoles::Table)
1206 .and_where(Expr::col(BackendRoles::Code).eq(code))
1207 .to_owned(),
1208 );
1209 let row = bind_values(sqlx::query(&sql), values)
1210 .fetch_optional(&db.pool)
1211 .await?;
1212 Ok(match row {
1213 Some(row) => Some(row.try_get::<i64, _>("id")?),
1214 None => None,
1215 })
1216}
1217
1218pub async fn role_permissions(db: &Db, id: i64) -> Result<Vec<String>, AuthError> {
1220 let (sql, values) = build(
1221 db.backend,
1222 Query::select()
1223 .column(BackendRoles::Permissions)
1224 .from(BackendRoles::Table)
1225 .and_where(Expr::col(BackendRoles::Id).eq(id))
1226 .to_owned(),
1227 );
1228 let row = bind_values(sqlx::query(&sql), values)
1229 .fetch_optional(&db.pool)
1230 .await?;
1231 Ok(match row {
1232 Some(row) => serde_json::from_str(&row.get_text("permissions")?).unwrap_or_default(),
1233 None => Vec::new(),
1234 })
1235}
1236
1237pub async fn role_is_system(db: &Db, id: i64) -> Result<bool, AuthError> {
1239 let (sql, values) = is_system_query(db.backend, id);
1240 let row = bind_values(sqlx::query(&sql), values)
1241 .fetch_optional(&db.pool)
1242 .await?;
1243 read_is_system(row)
1244}
1245
1246pub async fn role_is_system_on(
1249 conn: &mut sqlx::AnyConnection,
1250 backend: laterite_core::DbBackend,
1251 id: i64,
1252) -> Result<bool, AuthError> {
1253 let (sql, values) = is_system_query(backend, id);
1254 let row = bind_values(sqlx::query(&sql), values)
1255 .fetch_optional(&mut *conn)
1256 .await?;
1257 read_is_system(row)
1258}
1259
1260fn is_system_query(backend: laterite_core::DbBackend, id: i64) -> (String, sea_query::Values) {
1261 build(
1262 backend,
1263 Query::select()
1264 .column(BackendRoles::IsSystem)
1265 .from(BackendRoles::Table)
1266 .and_where(Expr::col(BackendRoles::Id).eq(id))
1267 .to_owned(),
1268 )
1269}
1270
1271fn read_is_system(row: Option<AnyRow>) -> Result<bool, AuthError> {
1273 Ok(match row {
1274 Some(row) => row.get_bool("is_system")?,
1275 None => false,
1276 })
1277}
1278
1279pub async fn rename_permissions(db: &Db, renames: &[(String, String)]) -> Result<usize, AuthError> {
1289 if renames.is_empty() {
1290 return Ok(0);
1291 }
1292 let mut changed = 0;
1293
1294 let (sql, values) = build(
1296 db.backend,
1297 Query::select()
1298 .columns([BackendRoles::Id, BackendRoles::Permissions])
1299 .from(BackendRoles::Table)
1300 .to_owned(),
1301 );
1302 for row in bind_values(sqlx::query(&sql), values)
1303 .fetch_all(&db.pool)
1304 .await?
1305 {
1306 let id = row.try_get::<i64, _>("id")?;
1307 let held: Vec<String> =
1308 serde_json::from_str(&row.get_text("permissions")?).unwrap_or_default();
1309 let mut next: Vec<String> = held
1310 .iter()
1311 .map(|code| apply_rename(code, renames))
1312 .collect();
1313 next.dedup_by(|a, b| a == b);
1315 let mut seen = std::collections::HashSet::new();
1316 next.retain(|code| seen.insert(code.clone()));
1317 if next != held {
1318 let json = serde_json::to_string(&next).unwrap_or_else(|_| "[]".to_string());
1319 let (sql, values) = build(
1320 db.backend,
1321 Query::update()
1322 .table(BackendRoles::Table)
1323 .value(BackendRoles::Permissions, json)
1324 .and_where(Expr::col(BackendRoles::Id).eq(id))
1325 .to_owned(),
1326 );
1327 bind_values(sqlx::query(&sql), values)
1328 .execute(&db.pool)
1329 .await?;
1330 changed += 1;
1331 }
1332 }
1333
1334 let (sql, values) = build(
1336 db.backend,
1337 Query::select()
1338 .columns([BackendUsers::Id, BackendUsers::Permissions])
1339 .from(BackendUsers::Table)
1340 .to_owned(),
1341 );
1342 for row in bind_values(sqlx::query(&sql), values)
1343 .fetch_all(&db.pool)
1344 .await?
1345 {
1346 let id = row.try_get::<i64, _>("id")?;
1347 let held: HashMap<String, i64> =
1348 serde_json::from_str(&row.get_text("permissions")?).unwrap_or_default();
1349 let next: HashMap<String, i64> = held
1350 .iter()
1351 .map(|(code, state)| (apply_rename(code, renames), *state))
1352 .collect();
1353 if next != held {
1354 let json = serde_json::to_string(&next).unwrap_or_else(|_| "{}".to_string());
1355 let (sql, values) = build(
1356 db.backend,
1357 Query::update()
1358 .table(BackendUsers::Table)
1359 .value(BackendUsers::Permissions, json)
1360 .and_where(Expr::col(BackendUsers::Id).eq(id))
1361 .to_owned(),
1362 );
1363 bind_values(sqlx::query(&sql), values)
1364 .execute(&db.pool)
1365 .await?;
1366 changed += 1;
1367 }
1368 }
1369 Ok(changed)
1370}
1371
1372fn apply_rename(code: &str, renames: &[(String, String)]) -> String {
1374 renames
1375 .iter()
1376 .find(|(old, _)| old == code)
1377 .map(|(_, new)| new.clone())
1378 .unwrap_or_else(|| code.to_string())
1379}
1380
1381pub struct RoleSummary {
1383 pub id: i64,
1384 pub code: String,
1385 pub name: String,
1386 pub is_system: bool,
1387 pub permissions: Vec<String>,
1390}
1391
1392pub async fn list_roles(db: &Db) -> Result<Vec<RoleSummary>, AuthError> {
1394 let (sql, values) = build(
1395 db.backend,
1396 Query::select()
1397 .columns([
1398 BackendRoles::Id,
1399 BackendRoles::Code,
1400 BackendRoles::Name,
1401 BackendRoles::IsSystem,
1402 BackendRoles::Permissions,
1403 ])
1404 .from(BackendRoles::Table)
1405 .order_by(BackendRoles::Name, Order::Asc)
1406 .to_owned(),
1407 );
1408 let rows = bind_values(sqlx::query(&sql), values)
1409 .fetch_all(&db.pool)
1410 .await?;
1411 rows.into_iter()
1412 .map(|row| {
1413 Ok(RoleSummary {
1414 id: row.try_get::<i64, _>("id")?,
1415 code: row.get_text("code")?,
1416 name: row.get_text("name")?,
1417 is_system: row.get_bool("is_system")?,
1418 permissions: serde_json::from_str(&row.get_text("permissions")?)
1419 .unwrap_or_default(),
1420 })
1421 })
1422 .collect()
1423}
1424
1425pub async fn user_role_ids(db: &Db, user_id: i64) -> Result<Vec<i64>, AuthError> {
1427 let (sql, values) = build(
1428 db.backend,
1429 Query::select()
1430 .column(BackendUserRoles::BackendRoleId)
1431 .from(BackendUserRoles::Table)
1432 .and_where(Expr::col(BackendUserRoles::BackendUserId).eq(user_id))
1433 .to_owned(),
1434 );
1435 let rows = bind_values(sqlx::query(&sql), values)
1436 .fetch_all(&db.pool)
1437 .await?;
1438 rows.into_iter()
1439 .map(|row| Ok(row.try_get::<i64, _>("backend_role_id")?))
1440 .collect()
1441}
1442
1443pub async fn set_user_roles(db: &Db, user_id: i64, role_ids: &[i64]) -> Result<(), AuthError> {
1447 let mut tx = db.pool.begin().await?;
1448 let (sql, values) = build(
1449 db.backend,
1450 Query::delete()
1451 .from_table(BackendUserRoles::Table)
1452 .and_where(Expr::col(BackendUserRoles::BackendUserId).eq(user_id))
1453 .to_owned(),
1454 );
1455 bind_values(sqlx::query(&sql), values)
1456 .execute(&mut *tx)
1457 .await?;
1458 for role_id in role_ids {
1459 let (sql, values) = build(
1460 db.backend,
1461 Query::insert()
1462 .into_table(BackendUserRoles::Table)
1463 .columns([
1464 BackendUserRoles::BackendUserId,
1465 BackendUserRoles::BackendRoleId,
1466 ])
1467 .values_panic([user_id.into(), (*role_id).into()])
1468 .on_conflict(on_conflict_ignore([
1469 BackendUserRoles::BackendUserId,
1470 BackendUserRoles::BackendRoleId,
1471 ]))
1472 .to_owned(),
1473 );
1474 bind_values(sqlx::query(&sql), values)
1475 .execute(&mut *tx)
1476 .await?;
1477 }
1478 tx.commit().await?;
1479 Ok(())
1480}
1481
1482pub async fn assign_role(db: &Db, user_id: i64, role_id: i64) -> Result<(), AuthError> {
1483 let (sql, values) = build(
1484 db.backend,
1485 Query::insert()
1486 .into_table(BackendUserRoles::Table)
1487 .columns([
1488 BackendUserRoles::BackendUserId,
1489 BackendUserRoles::BackendRoleId,
1490 ])
1491 .values_panic([user_id.into(), role_id.into()])
1492 .on_conflict(on_conflict_ignore([
1493 BackendUserRoles::BackendUserId,
1494 BackendUserRoles::BackendRoleId,
1495 ]))
1496 .to_owned(),
1497 );
1498 bind_values(sqlx::query(&sql), values)
1499 .execute(&db.pool)
1500 .await?;
1501 Ok(())
1502}
1503
1504pub(crate) async fn set_user_active(db: &Db, user_id: i64, active: bool) -> Result<u64, AuthError> {
1511 let mut tx = db.pool.begin().await?;
1512 let (sql, values) = build(
1513 db.backend,
1514 Query::update()
1515 .table(BackendUsers::Table)
1516 .value(BackendUsers::IsActive, active)
1517 .and_where(Expr::col(BackendUsers::Id).eq(user_id))
1518 .to_owned(),
1519 );
1520 let done = bind_values(sqlx::query(&sql), values)
1521 .execute(&mut *tx)
1522 .await?
1523 .rows_affected();
1524 if done > 0 && !active {
1525 let (sql, values) =
1526 revoke_user_sessions_query(db.backend, user_id, None, RevokeReason::Deactivated);
1527 bind_values(sqlx::query(&sql), values)
1528 .execute(&mut *tx)
1529 .await?;
1530 let (sql, values) =
1531 revoke_user_remember_tokens_query(db.backend, user_id, RevokeReason::Deactivated);
1532 bind_values(sqlx::query(&sql), values)
1533 .execute(&mut *tx)
1534 .await?;
1535 }
1536 tx.commit().await?;
1537 Ok(done)
1538}
1539
1540pub(crate) async fn other_active_superusers(db: &Db, excluding: i64) -> Result<i64, AuthError> {
1545 let (sql, values) = build(
1546 db.backend,
1547 Query::select()
1548 .expr(Expr::col(BackendUsers::Id).count())
1549 .from(BackendUsers::Table)
1550 .and_where(Expr::col(BackendUsers::IsSuperuser).eq(true))
1551 .and_where(Expr::col(BackendUsers::IsActive).eq(true))
1552 .and_where(Expr::col(BackendUsers::Id).ne(excluding))
1553 .to_owned(),
1554 );
1555 let row = bind_values(sqlx::query(&sql), values)
1556 .fetch_one(&db.pool)
1557 .await?;
1558 Ok(row.try_get::<i64, _>(0).unwrap_or(0))
1559}
1560
1561pub async fn list_backend_users(db: &Db) -> Result<Vec<BackendUserSummary>, AuthError> {
1563 let (sql, values) = build(
1564 db.backend,
1565 Query::select()
1566 .columns([
1567 BackendUsers::Id,
1568 BackendUsers::Username,
1569 BackendUsers::Email,
1570 BackendUsers::FirstName,
1571 BackendUsers::LastName,
1572 BackendUsers::IsSuperuser,
1573 BackendUsers::IsActive,
1574 BackendUsers::CreatedAt,
1575 ])
1576 .from(BackendUsers::Table)
1577 .order_by(BackendUsers::CreatedAt, Order::Asc)
1578 .to_owned(),
1579 );
1580 let rows = bind_values(sqlx::query(&sql), values)
1581 .fetch_all(&db.pool)
1582 .await?;
1583 rows.iter().map(summary_from_row).collect()
1584}
1585
1586pub async fn update_password_by_username(
1588 db: &Db,
1589 username: &str,
1590 password_hash: &str,
1591) -> Result<u64, AuthError> {
1592 let (sql, values) = build(
1593 db.backend,
1594 Query::update()
1595 .table(BackendUsers::Table)
1596 .value(BackendUsers::PasswordHash, password_hash)
1597 .value(BackendUsers::PasswordChangedAt, now_ts())
1598 .value(BackendUsers::UpdatedAt, now_ts())
1599 .and_where(Expr::col(BackendUsers::Username).eq(normalize_key(username)))
1600 .to_owned(),
1601 );
1602 let result = bind_values(sqlx::query(&sql), values)
1603 .execute(&db.pool)
1604 .await?;
1605 Ok(result.rows_affected())
1606}
1607
1608pub async fn clear_failed_attempts(db: &Db, username: &str) -> Result<u64, AuthError> {
1610 let (sql, values) = build(
1611 db.backend,
1612 Query::delete()
1613 .from_table(BackendAccessLog::Table)
1614 .and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
1615 .and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
1616 .to_owned(),
1617 );
1618 let result = bind_values(sqlx::query(&sql), values)
1619 .execute(&db.pool)
1620 .await?;
1621 Ok(result.rows_affected())
1622}
1623
1624pub async fn user_preference(
1626 db: &Db,
1627 user_id: i64,
1628 key: &str,
1629) -> Result<Option<String>, AuthError> {
1630 let (sql, values) = build(
1631 db.backend,
1632 Query::select()
1633 .column(BackendUserPreferences::Value)
1634 .from(BackendUserPreferences::Table)
1635 .and_where(Expr::col(BackendUserPreferences::UserId).eq(user_id))
1636 .and_where(Expr::col(BackendUserPreferences::PreferenceKey).eq(key))
1637 .to_owned(),
1638 );
1639 let row = bind_values(sqlx::query(&sql), values)
1640 .fetch_optional(&db.pool)
1641 .await?;
1642 Ok(row.and_then(|r| r.get_text_opt("value").ok().flatten()))
1643}
1644
1645pub async fn set_user_preference(
1651 db: &Db,
1652 user_id: i64,
1653 key: &str,
1654 value: &str,
1655) -> Result<(), AuthError> {
1656 let mut tx = db.pool.begin().await?;
1657 let (sql, values) = build(
1658 db.backend,
1659 Query::delete()
1660 .from_table(BackendUserPreferences::Table)
1661 .and_where(Expr::col(BackendUserPreferences::UserId).eq(user_id))
1662 .and_where(Expr::col(BackendUserPreferences::PreferenceKey).eq(key))
1663 .to_owned(),
1664 );
1665 bind_values(sqlx::query(&sql), values)
1666 .execute(&mut *tx)
1667 .await?;
1668 let (sql, values) = build(
1669 db.backend,
1670 Query::insert()
1671 .into_table(BackendUserPreferences::Table)
1672 .columns([
1673 BackendUserPreferences::UserId,
1674 BackendUserPreferences::PreferenceKey,
1675 BackendUserPreferences::Value,
1676 ])
1677 .values_panic([
1678 user_id.into(),
1679 key.to_string().into(),
1680 value.to_string().into(),
1681 ])
1682 .to_owned(),
1683 );
1684 bind_values(sqlx::query(&sql), values)
1685 .execute(&mut *tx)
1686 .await?;
1687 tx.commit().await?;
1688 Ok(())
1689}
1690
1691pub async fn clear_user_preference(db: &Db, user_id: i64, key: &str) -> Result<(), AuthError> {
1693 let (sql, values) = build(
1694 db.backend,
1695 Query::delete()
1696 .from_table(BackendUserPreferences::Table)
1697 .and_where(Expr::col(BackendUserPreferences::UserId).eq(user_id))
1698 .and_where(Expr::col(BackendUserPreferences::PreferenceKey).eq(key))
1699 .to_owned(),
1700 );
1701 bind_values(sqlx::query(&sql), values)
1702 .execute(&db.pool)
1703 .await?;
1704 Ok(())
1705}
1706
1707#[cfg(test)]
1708mod tests {
1709 use super::*;
1710 use laterite_core::testing::connect_test;
1711
1712 async fn operator(db: &Db) -> i64 {
1713 let svc = crate::AuthService::new(db.clone(), crate::AuthConfig::default());
1714 svc.create_superuser(crate::NewOperator {
1715 username: "root",
1716 email: "root@acme.test",
1717 first_name: "Root",
1718 last_name: None,
1719 password: "rootpw12345",
1720 timezone: None,
1721 })
1722 .await
1723 .unwrap()
1724 }
1725
1726 #[tokio::test]
1727 async fn a_preference_round_trips_and_replaces() {
1728 let (db, _guard) = connect_test(&[crate::migrations()]).await;
1729 let id = operator(&db).await;
1730
1731 assert_eq!(
1733 user_preference(&db, id, "list.columns./roles")
1734 .await
1735 .unwrap(),
1736 None
1737 );
1738
1739 set_user_preference(&db, id, "list.columns./roles", "code,name")
1740 .await
1741 .unwrap();
1742 assert_eq!(
1743 user_preference(&db, id, "list.columns./roles")
1744 .await
1745 .unwrap(),
1746 Some("code,name".to_string())
1747 );
1748
1749 set_user_preference(&db, id, "list.columns./roles", "code")
1752 .await
1753 .unwrap();
1754 assert_eq!(
1755 user_preference(&db, id, "list.columns./roles")
1756 .await
1757 .unwrap(),
1758 Some("code".to_string())
1759 );
1760
1761 clear_user_preference(&db, id, "list.columns./roles")
1762 .await
1763 .unwrap();
1764 assert_eq!(
1765 user_preference(&db, id, "list.columns./roles")
1766 .await
1767 .unwrap(),
1768 None
1769 );
1770 }
1771
1772 #[tokio::test]
1774 async fn preferences_are_scoped() {
1775 let (db, _guard) = connect_test(&[crate::migrations()]).await;
1776 let root = operator(&db).await;
1777 let hash = crate::password::hash_password("otherpw12345").unwrap();
1778 let other = create_user(&db, "other", "other@acme.test", "Other", None, &hash, false)
1779 .await
1780 .unwrap();
1781
1782 set_user_preference(&db, root, "list.columns./roles", "code")
1783 .await
1784 .unwrap();
1785 assert_eq!(
1786 user_preference(&db, other, "list.columns./roles")
1787 .await
1788 .unwrap(),
1789 None
1790 );
1791 assert_eq!(
1792 user_preference(&db, root, "list.columns./users")
1793 .await
1794 .unwrap(),
1795 None
1796 );
1797 }
1798}