Skip to main content

laterite_auth/
store.rs

1//! Data access for the auth schema.
2//!
3//! Free functions over a [`Db`] (pool plus backend). Queries are built with
4//! `sea-query` and bound through `laterite_core::query`, so they run on any
5//! supported backend. Portability at the boundary: ids are `bigint`
6//! auto-increment keys (the database assigns them, read back through
7//! [`laterite_core::query::insert_returning_id`]), timestamps are stored as text
8//! and converted to `DateTime<Utc>` here, and permission collections are stored
9//! as JSON text.
10//!
11//! Session rows are the exception to the module being public. Reading one is
12//! what pushes its idle clock forward, so a caller that reached the row directly
13//! would authenticate a request and leave the session ageing as though the
14//! request never happened. The session functions are therefore crate-visible and
15//! [`crate::AuthService`] is the only way in: the renewal cannot be skipped
16//! because there is no path that skips it.
17
18use 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
50/// Normalises a user-facing key (username or email) so lookups, uniqueness, and
51/// login throttling behave identically on every backend. MySQL's default
52/// collation is case-insensitive (and trailing-space-insensitive) while Postgres
53/// and SQLite are case-sensitive, so the framework lower-cases and trims these
54/// keys before storing or matching. Apply it on every write and lookup of a
55/// username or email so `Root` and `root ` resolve to the same account anywhere.
56fn 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
105/// Looks up a user by username without filtering on active state.
106pub 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
124/// Looks up an active user by id, used when resolving a session to an identity.
125/// A user by id whatever their state. Distinct from
126/// [`find_active_user_by_id`], which is the authentication path: this one is for
127/// administering an account, where a deactivated one still has to be readable.
128/// Looks up a user by id, active or not.
129pub async fn find_user_by_id(db: &Db, id: i64) -> Result<Option<BackendUser>, AuthError> {
130    let (sql, values) = build(
131        db.backend,
132        Query::select()
133            .columns(USER_COLS)
134            .from(BackendUsers::Table)
135            .and_where(Expr::col(BackendUsers::Id).eq(id))
136            .to_owned(),
137    );
138    let row = bind_values(sqlx::query(&sql), values)
139        .fetch_optional(&db.pool)
140        .await?;
141    row.map(|r| user_from_row(&r)).transpose()
142}
143
144pub async fn find_active_user_by_id(db: &Db, id: i64) -> Result<Option<BackendUser>, AuthError> {
145    let (sql, values) = build(
146        db.backend,
147        Query::select()
148            .columns(USER_COLS)
149            .from(BackendUsers::Table)
150            .and_where(Expr::col(BackendUsers::Id).eq(id))
151            .and_where(Expr::col(BackendUsers::IsActive).eq(true))
152            .to_owned(),
153    );
154    let row = bind_values(sqlx::query(&sql), values)
155        .fetch_optional(&db.pool)
156        .await?;
157    row.map(|r| user_from_row(&r)).transpose()
158}
159
160/// Returns the permission lists of every role assigned to a user (each stored
161/// as a JSON array), for the service to flatten into a permission set.
162pub async fn load_role_permissions(db: &Db, user_id: i64) -> Result<Vec<Vec<String>>, AuthError> {
163    let (sql, values) = build(
164        db.backend,
165        Query::select()
166            .column((BackendRoles::Table, BackendRoles::Permissions))
167            .from(BackendUserRoles::Table)
168            .inner_join(
169                BackendRoles::Table,
170                Expr::col((BackendRoles::Table, BackendRoles::Id))
171                    .equals((BackendUserRoles::Table, BackendUserRoles::BackendRoleId)),
172            )
173            .and_where(
174                Expr::col((BackendUserRoles::Table, BackendUserRoles::BackendUserId)).eq(user_id),
175            )
176            .to_owned(),
177    );
178    let rows = bind_values(sqlx::query(&sql), values)
179        .fetch_all(&db.pool)
180        .await?;
181    let mut out = Vec::with_capacity(rows.len());
182    for row in rows {
183        let json = row.get_text("permissions")?;
184        let perms: Vec<String> = serde_json::from_str(&json)
185            .map_err(|e| AuthError::Data(format!("role permissions: {e}")))?;
186        out.push(perms);
187    }
188    Ok(out)
189}
190
191/// Loads a user's per-permission overrides: a map of permission code to `1`
192/// (allow) or `-1` (deny).
193pub async fn load_user_permission_overrides(
194    db: &Db,
195    user_id: i64,
196) -> Result<HashMap<String, i64>, AuthError> {
197    let (sql, values) = build(
198        db.backend,
199        Query::select()
200            .column(BackendUsers::Permissions)
201            .from(BackendUsers::Table)
202            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
203            .to_owned(),
204    );
205    let row = bind_values(sqlx::query(&sql), values)
206        .fetch_optional(&db.pool)
207        .await?;
208    let overrides = match row {
209        Some(r) => {
210            let json = r.get_text("permissions")?;
211            serde_json::from_str(&json).unwrap_or_default()
212        }
213        None => HashMap::new(),
214    };
215    Ok(overrides)
216}
217
218/// Replaces a user's per-permission overrides (stored as a JSON object).
219pub async fn set_user_permissions(
220    db: &Db,
221    user_id: i64,
222    overrides: &HashMap<String, i64>,
223) -> Result<(), AuthError> {
224    let json = serde_json::to_string(overrides).unwrap_or_else(|_| "{}".to_string());
225    let (sql, values) = build(
226        db.backend,
227        Query::update()
228            .table(BackendUsers::Table)
229            .value(BackendUsers::Permissions, json)
230            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
231            .to_owned(),
232    );
233    bind_values(sqlx::query(&sql), values)
234        .execute(&db.pool)
235        .await?;
236    Ok(())
237}
238
239pub(crate) async fn insert_session(
240    db: &Db,
241    token_hash: &str,
242    user_id: i64,
243    expires_at: DateTime<Utc>,
244    ip_address: Option<&str>,
245    user_agent: Option<&str>,
246) -> Result<(), AuthError> {
247    let now = now_ts();
248    let (sql, values) = build(
249        db.backend,
250        Query::insert()
251            .into_table(BackendSessions::Table)
252            .columns([
253                BackendSessions::TokenHash,
254                BackendSessions::BackendUserId,
255                BackendSessions::CreatedAt,
256                BackendSessions::LastSeenAt,
257                BackendSessions::ExpiresAt,
258                BackendSessions::IpAddress,
259                BackendSessions::UserAgent,
260            ])
261            .values_panic([
262                token_hash.into(),
263                user_id.into(),
264                now.clone().into(),
265                now.into(),
266                ts(expires_at).into(),
267                ip_address.into(),
268                user_agent.into(),
269            ])
270            .to_owned(),
271    );
272    bind_values(sqlx::query(&sql), values)
273        .execute(&db.pool)
274        .await?;
275    Ok(())
276}
277
278/// A resolved non-expired session: its owning user and opaque data blob.
279pub struct ValidSession {
280    pub user_id: i64,
281    pub data: Option<String>,
282    /// When the session was issued. Fixes the absolute ceiling.
283    pub created_at: DateTime<Utc>,
284    /// Last request on this session. Fixes the idle deadline.
285    pub last_seen_at: DateTime<Utc>,
286}
287
288/// Returns a non-expired session (owner id plus its data blob), if any. The
289/// blob is read in the same query, so exposing session state costs no extra
290/// round-trip.
291pub(crate) async fn find_valid_session(
292    db: &Db,
293    token_hash: &str,
294    now: DateTime<Utc>,
295) -> Result<Option<ValidSession>, AuthError> {
296    let (sql, values) = build(
297        db.backend,
298        Query::select()
299            .columns([
300                BackendSessions::BackendUserId,
301                BackendSessions::Data,
302                BackendSessions::CreatedAt,
303                BackendSessions::LastSeenAt,
304            ])
305            .from(BackendSessions::Table)
306            .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
307            .and_where(Expr::col(BackendSessions::ExpiresAt).gt(ts(now)))
308            .and_where(Expr::col(BackendSessions::RevokedReason).is_null())
309            .to_owned(),
310    );
311    let row = bind_values(sqlx::query(&sql), values)
312        .fetch_optional(&db.pool)
313        .await?;
314    match row {
315        Some(r) => Ok(Some(ValidSession {
316            user_id: r.try_get::<i64, _>("backend_user_id")?,
317            data: r.get_text_opt("data")?,
318            created_at: parse_ts(&r.get_text("created_at")?)?,
319            last_seen_at: parse_ts(&r.get_text("last_seen_at")?)?,
320        })),
321        None => Ok(None),
322    }
323}
324
325/// Overwrites a session's opaque data blob. Callers write only when the blob
326/// changed, so an unchanged request adds no write.
327pub(crate) async fn set_session_data(
328    db: &Db,
329    token_hash: &str,
330    data: &str,
331) -> Result<(), AuthError> {
332    let (sql, values) = build(
333        db.backend,
334        Query::update()
335            .table(BackendSessions::Table)
336            .value(BackendSessions::Data, data)
337            .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
338            .to_owned(),
339    );
340    bind_values(sqlx::query(&sql), values)
341        .execute(&db.pool)
342        .await?;
343    Ok(())
344}
345
346/// Pushes a session's idle clock and its effective deadline out together. The
347/// two are written in one statement so a reader can never see a refreshed
348/// last-seen against a stale deadline.
349pub(crate) async fn renew_session(
350    db: &Db,
351    token_hash: &str,
352    now: DateTime<Utc>,
353    expires_at: DateTime<Utc>,
354) -> Result<(), AuthError> {
355    let (sql, values) = build(
356        db.backend,
357        Query::update()
358            .table(BackendSessions::Table)
359            .value(BackendSessions::LastSeenAt, ts(now))
360            .value(BackendSessions::ExpiresAt, ts(expires_at))
361            .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
362            .to_owned(),
363    );
364    bind_values(sqlx::query(&sql), values)
365        .execute(&db.pool)
366        .await?;
367    Ok(())
368}
369
370/// A live "stay signed in" credential. `verifier_hash` is checked by the
371/// caller; the row is found by selector alone so a wrong verifier still costs
372/// one indexed lookup and nothing more.
373pub(crate) struct RememberToken {
374    pub user_id: i64,
375    pub verifier_hash: String,
376}
377
378pub(crate) async fn insert_remember_token(
379    db: &Db,
380    selector: &str,
381    verifier_hash: &str,
382    user_id: i64,
383    expires_at: DateTime<Utc>,
384) -> Result<(), AuthError> {
385    let (sql, values) = build(
386        db.backend,
387        Query::insert()
388            .into_table(BackendRememberTokens::Table)
389            .columns([
390                BackendRememberTokens::Selector,
391                BackendRememberTokens::VerifierHash,
392                BackendRememberTokens::BackendUserId,
393                BackendRememberTokens::CreatedAt,
394                BackendRememberTokens::ExpiresAt,
395            ])
396            .values_panic([
397                selector.into(),
398                verifier_hash.into(),
399                user_id.into(),
400                now_ts().into(),
401                ts(expires_at).into(),
402            ])
403            .to_owned(),
404    );
405    bind_values(sqlx::query(&sql), values)
406        .execute(&db.pool)
407        .await?;
408    Ok(())
409}
410
411pub(crate) async fn find_remember_token(
412    db: &Db,
413    selector: &str,
414    now: DateTime<Utc>,
415) -> Result<Option<RememberToken>, AuthError> {
416    let (sql, values) = build(
417        db.backend,
418        Query::select()
419            .columns([
420                BackendRememberTokens::BackendUserId,
421                BackendRememberTokens::VerifierHash,
422            ])
423            .from(BackendRememberTokens::Table)
424            .and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
425            .and_where(Expr::col(BackendRememberTokens::ExpiresAt).gt(ts(now)))
426            .and_where(Expr::col(BackendRememberTokens::RevokedReason).is_null())
427            .to_owned(),
428    );
429    let row = bind_values(sqlx::query(&sql), values)
430        .fetch_optional(&db.pool)
431        .await?;
432    match row {
433        Some(r) => Ok(Some(RememberToken {
434            user_id: r.try_get::<i64, _>("backend_user_id")?,
435            verifier_hash: r.get_text("verifier_hash")?,
436        })),
437        None => Ok(None),
438    }
439}
440
441/// Swaps in a new secret for a credential, keeping its selector. The selector
442/// is the series: holding it stable is what lets a stale secret be recognised
443/// as a copy rather than mistaken for an unknown credential.
444pub(crate) async fn rotate_remember_token(
445    db: &Db,
446    selector: &str,
447    verifier_hash: &str,
448    expires_at: DateTime<Utc>,
449) -> Result<(), AuthError> {
450    let (sql, values) = build(
451        db.backend,
452        Query::update()
453            .table(BackendRememberTokens::Table)
454            .value(BackendRememberTokens::VerifierHash, verifier_hash)
455            .value(BackendRememberTokens::ExpiresAt, ts(expires_at))
456            .and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
457            .to_owned(),
458    );
459    bind_values(sqlx::query(&sql), values)
460        .execute(&db.pool)
461        .await?;
462    Ok(())
463}
464
465pub(crate) async fn delete_remember_token(db: &Db, selector: &str) -> Result<(), AuthError> {
466    let (sql, values) = build(
467        db.backend,
468        Query::delete()
469            .from_table(BackendRememberTokens::Table)
470            .and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
471            .to_owned(),
472    );
473    bind_values(sqlx::query(&sql), values)
474        .execute(&db.pool)
475        .await?;
476    Ok(())
477}
478
479/// Ends a user's stay-signed-in credentials on purpose, recording why.
480pub(crate) async fn revoke_user_remember_tokens(
481    db: &Db,
482    user_id: i64,
483    reason: RevokeReason,
484) -> Result<(), AuthError> {
485    let (sql, values) = revoke_user_remember_tokens_query(db.backend, user_id, reason);
486    bind_values(sqlx::query(&sql), values)
487        .execute(&db.pool)
488        .await?;
489    Ok(())
490}
491
492fn revoke_user_remember_tokens_query(
493    backend: laterite_core::DbBackend,
494    user_id: i64,
495    reason: RevokeReason,
496) -> (String, sea_query::Values) {
497    build(
498        backend,
499        Query::update()
500            .table(BackendRememberTokens::Table)
501            .value(BackendRememberTokens::RevokedReason, reason.as_str())
502            .and_where(Expr::col(BackendRememberTokens::BackendUserId).eq(user_id))
503            .and_where(Expr::col(BackendRememberTokens::RevokedReason).is_null())
504            .to_owned(),
505    )
506}
507
508/// A revoked credential by selector: its verifier hash and why it ended.
509pub(crate) async fn revoked_remember_token(
510    db: &Db,
511    selector: &str,
512) -> Result<Option<(String, RevokeReason)>, AuthError> {
513    let (sql, values) = build(
514        db.backend,
515        Query::select()
516            .columns([
517                BackendRememberTokens::VerifierHash,
518                BackendRememberTokens::RevokedReason,
519            ])
520            .from(BackendRememberTokens::Table)
521            .and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
522            .and_where(Expr::col(BackendRememberTokens::RevokedReason).is_not_null())
523            .to_owned(),
524    );
525    let Some(row) = bind_values(sqlx::query(&sql), values)
526        .fetch_optional(&db.pool)
527        .await?
528    else {
529        return Ok(None);
530    };
531    let reason = row
532        .get_text_opt("revoked_reason")?
533        .as_deref()
534        .and_then(RevokeReason::parse);
535    Ok(reason.map(|r| (row.get_text("verifier_hash").unwrap_or_default(), r)))
536}
537
538/// Drops every remember credential a user holds. The answer to a verifier
539/// mismatch, which means a copy of a cookie is in circulation, and to a
540/// deliberate "sign out everywhere".
541pub(crate) async fn delete_user_remember_tokens(db: &Db, user_id: i64) -> Result<(), AuthError> {
542    let (sql, values) = build(
543        db.backend,
544        Query::delete()
545            .from_table(BackendRememberTokens::Table)
546            .and_where(Expr::col(BackendRememberTokens::BackendUserId).eq(user_id))
547            .to_owned(),
548    );
549    bind_values(sqlx::query(&sql), values)
550        .execute(&db.pool)
551        .await?;
552    Ok(())
553}
554
555/// One live session of a user, for the account's own sessions list.
556pub(crate) struct SessionSummary {
557    pub token_hash: String,
558    pub created_at: DateTime<Utc>,
559    pub last_seen_at: DateTime<Utc>,
560    pub expires_at: DateTime<Utc>,
561    pub ip_address: Option<String>,
562    pub user_agent: Option<String>,
563}
564
565pub(crate) async fn list_user_sessions(
566    db: &Db,
567    user_id: i64,
568    now: DateTime<Utc>,
569) -> Result<Vec<SessionSummary>, AuthError> {
570    let (sql, values) = build(
571        db.backend,
572        Query::select()
573            .columns([
574                BackendSessions::TokenHash,
575                BackendSessions::CreatedAt,
576                BackendSessions::LastSeenAt,
577                BackendSessions::ExpiresAt,
578                BackendSessions::IpAddress,
579                BackendSessions::UserAgent,
580            ])
581            .from(BackendSessions::Table)
582            .and_where(Expr::col(BackendSessions::BackendUserId).eq(user_id))
583            .and_where(Expr::col(BackendSessions::ExpiresAt).gt(ts(now)))
584            .and_where(Expr::col(BackendSessions::RevokedReason).is_null())
585            .order_by(BackendSessions::LastSeenAt, Order::Desc)
586            .to_owned(),
587    );
588    let rows = bind_values(sqlx::query(&sql), values)
589        .fetch_all(&db.pool)
590        .await?;
591    rows.iter()
592        .map(|r| {
593            Ok(SessionSummary {
594                token_hash: r.get_text("token_hash")?,
595                created_at: parse_ts(&r.get_text("created_at")?)?,
596                last_seen_at: parse_ts(&r.get_text("last_seen_at")?)?,
597                expires_at: parse_ts(&r.get_text("expires_at")?)?,
598                ip_address: r.get_text_opt("ip_address")?,
599                user_agent: r.get_text_opt("user_agent")?,
600            })
601        })
602        .collect()
603}
604
605/// Deletes one of a user's sessions. Scoped by owner, so a forged id can only
606/// ever end one of the caller's own sessions.
607/// Ends one of a user's sessions with a reason, so its holder is told.
608pub(crate) async fn revoke_user_session(
609    db: &Db,
610    user_id: i64,
611    token_hash: &str,
612    reason: RevokeReason,
613) -> Result<u64, AuthError> {
614    let (sql, values) = build(
615        db.backend,
616        Query::update()
617            .table(BackendSessions::Table)
618            .value(BackendSessions::RevokedReason, reason.as_str())
619            .and_where(Expr::col(BackendSessions::BackendUserId).eq(user_id))
620            .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
621            .and_where(Expr::col(BackendSessions::RevokedReason).is_null())
622            .to_owned(),
623    );
624    let done = bind_values(sqlx::query(&sql), values)
625        .execute(&db.pool)
626        .await?;
627    Ok(done.rows_affected())
628}
629
630/// Why a session was ended deliberately. The set is closed: it becomes a code
631/// on a redirect and a sentence on the login screen, so nothing a caller wrote
632/// is ever shown back to anyone.
633#[derive(Debug, Clone, Copy, PartialEq, Eq)]
634#[non_exhaustive]
635pub enum RevokeReason {
636    /// The account's password changed.
637    PasswordChanged,
638    /// The account was deactivated.
639    Deactivated,
640    /// Someone chose to sign out every other device.
641    SignedOutElsewhere,
642}
643
644impl RevokeReason {
645    pub fn as_str(self) -> &'static str {
646        match self {
647            RevokeReason::PasswordChanged => "password_changed",
648            RevokeReason::Deactivated => "deactivated",
649            RevokeReason::SignedOutElsewhere => "signed_out_elsewhere",
650        }
651    }
652
653    pub fn parse(code: &str) -> Option<Self> {
654        match code {
655            "password_changed" => Some(RevokeReason::PasswordChanged),
656            "deactivated" => Some(RevokeReason::Deactivated),
657            "signed_out_elsewhere" => Some(RevokeReason::SignedOutElsewhere),
658            _ => None,
659        }
660    }
661}
662
663/// How a session that no longer works came to an end.
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665#[non_exhaustive]
666pub enum SessionEnd {
667    /// It ran out: idle too long, or past its ceiling.
668    Expired,
669    /// It was ended on purpose.
670    Revoked(RevokeReason),
671}
672
673impl SessionEnd {
674    /// A stable code for the ending: `expired`, or the revoke reason's code.
675    pub fn code(self) -> &'static str {
676        match self {
677            SessionEnd::Expired => "expired",
678            SessionEnd::Revoked(reason) => reason.as_str(),
679        }
680    }
681}
682
683/// Ends a user's sessions on purpose, recording why, except `keep` when given.
684///
685/// The row stays until it is next presented, so whoever holds it can be told
686/// why they were signed out.
687pub(crate) async fn revoke_user_sessions(
688    db: &Db,
689    user_id: i64,
690    keep: Option<&str>,
691    reason: RevokeReason,
692) -> Result<u64, AuthError> {
693    let (sql, values) = revoke_user_sessions_query(db.backend, user_id, keep, reason);
694    let done = bind_values(sqlx::query(&sql), values)
695        .execute(&db.pool)
696        .await?;
697    Ok(done.rows_affected())
698}
699
700fn revoke_user_sessions_query(
701    backend: laterite_core::DbBackend,
702    user_id: i64,
703    keep: Option<&str>,
704    reason: RevokeReason,
705) -> (String, sea_query::Values) {
706    let mut update = Query::update();
707    update
708        .table(BackendSessions::Table)
709        .value(BackendSessions::RevokedReason, reason.as_str())
710        .and_where(Expr::col(BackendSessions::BackendUserId).eq(user_id))
711        .and_where(Expr::col(BackendSessions::RevokedReason).is_null());
712    if let Some(keep) = keep {
713        update.and_where(Expr::col(BackendSessions::TokenHash).ne(keep));
714    }
715    build(backend, update.to_owned())
716}
717
718/// Why a presented session no longer works, or `None` for one never issued.
719pub(crate) async fn session_end(
720    db: &Db,
721    token_hash: &str,
722    now: DateTime<Utc>,
723) -> Result<Option<SessionEnd>, AuthError> {
724    let (sql, values) = build(
725        db.backend,
726        Query::select()
727            .columns([BackendSessions::RevokedReason, BackendSessions::ExpiresAt])
728            .from(BackendSessions::Table)
729            .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
730            .to_owned(),
731    );
732    let Some(row) = bind_values(sqlx::query(&sql), values)
733        .fetch_optional(&db.pool)
734        .await?
735    else {
736        return Ok(None);
737    };
738    if let Some(reason) = row
739        .get_text_opt("revoked_reason")?
740        .as_deref()
741        .and_then(RevokeReason::parse)
742    {
743        return Ok(Some(SessionEnd::Revoked(reason)));
744    }
745    let expires = parse_ts(&row.get_text("expires_at")?)?;
746    Ok((expires <= now).then_some(SessionEnd::Expired))
747}
748
749/// Sets a user's password hash, ends every other session and stay-signed-in
750/// credential with [`RevokeReason::PasswordChanged`], and writes the audit entry,
751/// all in one transaction: a change that half-landed would leave a new password
752/// beside sessions the old one opened. Returns how many user rows matched;
753/// nothing is written for an unknown id.
754pub(crate) async fn change_password(
755    db: &Db,
756    user_id: i64,
757    password_hash: &str,
758    keep: Option<&str>,
759    actor_user_id: Option<i64>,
760    actor_username: &str,
761) -> Result<u64, AuthError> {
762    let mut tx = db.pool.begin().await?;
763    let (sql, values) = build(
764        db.backend,
765        Query::select()
766            .column(BackendUsers::Username)
767            .from(BackendUsers::Table)
768            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
769            .to_owned(),
770    );
771    let Some(username) = bind_values(sqlx::query(&sql), values)
772        .fetch_optional(&mut *tx)
773        .await?
774        .map(|row| row.get_text("username"))
775        .transpose()?
776    else {
777        return Ok(0);
778    };
779    let (sql, values) = build(
780        db.backend,
781        Query::update()
782            .table(BackendUsers::Table)
783            .value(BackendUsers::PasswordHash, password_hash)
784            .value(BackendUsers::PasswordChangedAt, now_ts())
785            .value(BackendUsers::MustChangePassword, false)
786            .value(BackendUsers::UpdatedAt, now_ts())
787            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
788            .to_owned(),
789    );
790    let done = bind_values(sqlx::query(&sql), values)
791        .execute(&mut *tx)
792        .await?
793        .rows_affected();
794    if done == 0 {
795        return Ok(0);
796    }
797    let (sql, values) =
798        revoke_user_sessions_query(db.backend, user_id, keep, RevokeReason::PasswordChanged);
799    bind_values(sqlx::query(&sql), values)
800        .execute(&mut *tx)
801        .await?;
802    let (sql, values) =
803        revoke_user_remember_tokens_query(db.backend, user_id, RevokeReason::PasswordChanged);
804    bind_values(sqlx::query(&sql), values)
805        .execute(&mut *tx)
806        .await?;
807    let target = user_id.to_string();
808    let (sql, values) = insert_audit_log_query(
809        db.backend,
810        actor_user_id,
811        actor_username,
812        "backend.user.password_change",
813        Some("backend_user"),
814        Some(&target),
815        Some(&username),
816        None,
817    );
818    bind_values(sqlx::query(&sql), values)
819        .execute(&mut *tx)
820        .await?;
821    tx.commit().await?;
822    Ok(done)
823}
824
825/// Removes every session past its expiry, revoked or not: a holder who never
826/// returned before the session would have ended anyway is not told why.
827pub(crate) async fn delete_expired_sessions(db: &Db, now: DateTime<Utc>) -> Result<u64, AuthError> {
828    let (sql, values) = build(
829        db.backend,
830        Query::delete()
831            .from_table(BackendSessions::Table)
832            .and_where(Expr::col(BackendSessions::ExpiresAt).lte(ts(now)))
833            .to_owned(),
834    );
835    let done = bind_values(sqlx::query(&sql), values)
836        .execute(&db.pool)
837        .await?;
838    Ok(done.rows_affected())
839}
840
841/// Removes every stay-signed-in credential past its expiry, revoked or not.
842pub(crate) async fn delete_expired_remember_tokens(
843    db: &Db,
844    now: DateTime<Utc>,
845) -> Result<u64, AuthError> {
846    let (sql, values) = build(
847        db.backend,
848        Query::delete()
849            .from_table(BackendRememberTokens::Table)
850            .and_where(Expr::col(BackendRememberTokens::ExpiresAt).lte(ts(now)))
851            .to_owned(),
852    );
853    let done = bind_values(sqlx::query(&sql), values)
854        .execute(&db.pool)
855        .await?;
856    Ok(done.rows_affected())
857}
858
859/// Whether the account holds a temporary password it must replace.
860pub(crate) async fn must_change_password(db: &Db, user_id: i64) -> Result<bool, AuthError> {
861    let (sql, values) = build(
862        db.backend,
863        Query::select()
864            .column(BackendUsers::MustChangePassword)
865            .from(BackendUsers::Table)
866            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
867            .to_owned(),
868    );
869    let row = bind_values(sqlx::query(&sql), values)
870        .fetch_optional(&db.pool)
871        .await?;
872    match row {
873        Some(row) => Ok(row.get_bool("must_change_password")?),
874        None => Ok(false),
875    }
876}
877
878/// Marks or clears the account as holding a temporary password.
879pub(crate) async fn set_must_change_password(
880    db: &Db,
881    user_id: i64,
882    must_change: bool,
883) -> Result<u64, AuthError> {
884    let (sql, values) = build(
885        db.backend,
886        Query::update()
887            .table(BackendUsers::Table)
888            .value(BackendUsers::MustChangePassword, must_change)
889            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
890            .to_owned(),
891    );
892    let done = bind_values(sqlx::query(&sql), values)
893        .execute(&db.pool)
894        .await?;
895    Ok(done.rows_affected())
896}
897
898/// When a user's password was last set, `None` if it has not changed since
899/// the time was first recorded.
900pub(crate) async fn password_changed_at(
901    db: &Db,
902    user_id: i64,
903) -> Result<Option<DateTime<Utc>>, AuthError> {
904    let (sql, values) = build(
905        db.backend,
906        Query::select()
907            .column(BackendUsers::PasswordChangedAt)
908            .from(BackendUsers::Table)
909            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
910            .to_owned(),
911    );
912    let row = bind_values(sqlx::query(&sql), values)
913        .fetch_optional(&db.pool)
914        .await?;
915    match row {
916        Some(row) => row
917            .get_text_opt("password_changed_at")?
918            .map(|s| parse_ts(&s))
919            .transpose(),
920        None => Ok(None),
921    }
922}
923
924pub(crate) async fn delete_session(db: &Db, token_hash: &str) -> Result<(), AuthError> {
925    let (sql, values) = build(
926        db.backend,
927        Query::delete()
928            .from_table(BackendSessions::Table)
929            .and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
930            .to_owned(),
931    );
932    bind_values(sqlx::query(&sql), values)
933        .execute(&db.pool)
934        .await?;
935    Ok(())
936}
937
938pub async fn insert_access_log(
939    db: &Db,
940    user_id: Option<i64>,
941    username_attempted: &str,
942    event: AccessEvent,
943    ip_address: Option<&str>,
944    user_agent: Option<&str>,
945) -> Result<(), AuthError> {
946    let (sql, values) = build(
947        db.backend,
948        Query::insert()
949            .into_table(BackendAccessLog::Table)
950            .columns([
951                BackendAccessLog::BackendUserId,
952                BackendAccessLog::UsernameAttempted,
953                BackendAccessLog::Event,
954                BackendAccessLog::IpAddress,
955                BackendAccessLog::UserAgent,
956                BackendAccessLog::CreatedAt,
957            ])
958            .values_panic([
959                user_id.into(),
960                normalize_key(username_attempted).into(),
961                event.as_str().into(),
962                ip_address.map(str::to_string).into(),
963                user_agent.map(str::to_string).into(),
964                now_ts().into(),
965            ])
966            .to_owned(),
967    );
968    bind_values(sqlx::query(&sql), values)
969        .execute(&db.pool)
970        .await?;
971    Ok(())
972}
973
974/// One row of the access log, newest first from [`recent_access_log`].
975#[derive(Debug, Clone)]
976#[non_exhaustive]
977pub struct AccessRecord {
978    /// The stored form of an [`AccessEvent`].
979    pub event: String,
980    pub username_attempted: String,
981    pub ip_address: Option<String>,
982    pub user_agent: Option<String>,
983    pub created_at: DateTime<Utc>,
984}
985
986/// The most recent access-log rows for one account, newest first.
987pub async fn recent_access_log(
988    db: &Db,
989    user_id: i64,
990    limit: u64,
991) -> Result<Vec<AccessRecord>, AuthError> {
992    let (sql, values) = build(
993        db.backend,
994        Query::select()
995            .columns([
996                BackendAccessLog::Event,
997                BackendAccessLog::UsernameAttempted,
998                BackendAccessLog::IpAddress,
999                BackendAccessLog::UserAgent,
1000                BackendAccessLog::CreatedAt,
1001            ])
1002            .from(BackendAccessLog::Table)
1003            .and_where(Expr::col(BackendAccessLog::BackendUserId).eq(user_id))
1004            .order_by(BackendAccessLog::Id, Order::Desc)
1005            .limit(limit)
1006            .to_owned(),
1007    );
1008    let rows = bind_values(sqlx::query(&sql), values)
1009        .fetch_all(&db.pool)
1010        .await?;
1011    rows.iter()
1012        .map(|row| {
1013            Ok(AccessRecord {
1014                event: row.get_text("event")?,
1015                username_attempted: row.get_text("username_attempted")?,
1016                ip_address: row.get_text_opt("ip_address")?,
1017                user_agent: row.get_text_opt("user_agent")?,
1018                created_at: parse_ts(&row.get_text("created_at")?)?,
1019            })
1020        })
1021        .collect()
1022}
1023
1024/// One row of the audit log, newest-first from [`recent_audit`].
1025#[derive(Debug, Clone)]
1026#[non_exhaustive]
1027pub struct AuditRecord {
1028    pub actor_user_id: Option<i64>,
1029    pub actor_username: String,
1030    pub action: String,
1031    pub target_type: Option<String>,
1032    pub target_id: Option<String>,
1033    /// The target's name as operators know it, when the writer had one.
1034    pub target_label: Option<String>,
1035    pub detail: Option<String>,
1036    pub created_at: DateTime<Utc>,
1037}
1038
1039/// Appends one entry to the append-only audit log.
1040#[allow(clippy::too_many_arguments)]
1041pub async fn insert_audit_log(
1042    db: &Db,
1043    actor_user_id: Option<i64>,
1044    actor_username: &str,
1045    action: &str,
1046    target_type: Option<&str>,
1047    target_id: Option<&str>,
1048    target_label: Option<&str>,
1049    detail: Option<&str>,
1050) -> Result<(), AuthError> {
1051    let (sql, values) = insert_audit_log_query(
1052        db.backend,
1053        actor_user_id,
1054        actor_username,
1055        action,
1056        target_type,
1057        target_id,
1058        target_label,
1059        detail,
1060    );
1061    bind_values(sqlx::query(&sql), values)
1062        .execute(&db.pool)
1063        .await?;
1064    Ok(())
1065}
1066
1067#[allow(clippy::too_many_arguments)]
1068fn insert_audit_log_query(
1069    backend: laterite_core::DbBackend,
1070    actor_user_id: Option<i64>,
1071    actor_username: &str,
1072    action: &str,
1073    target_type: Option<&str>,
1074    target_id: Option<&str>,
1075    target_label: Option<&str>,
1076    detail: Option<&str>,
1077) -> (String, sea_query::Values) {
1078    build(
1079        backend,
1080        Query::insert()
1081            .into_table(BackendAuditLog::Table)
1082            .columns([
1083                BackendAuditLog::ActorUserId,
1084                BackendAuditLog::ActorUsername,
1085                BackendAuditLog::Action,
1086                BackendAuditLog::TargetType,
1087                BackendAuditLog::TargetId,
1088                BackendAuditLog::TargetLabel,
1089                BackendAuditLog::Detail,
1090                BackendAuditLog::CreatedAt,
1091            ])
1092            .values_panic([
1093                actor_user_id.into(),
1094                actor_username.to_string().into(),
1095                action.to_string().into(),
1096                target_type.map(str::to_string).into(),
1097                target_id.map(str::to_string).into(),
1098                target_label.map(str::to_string).into(),
1099                detail.map(str::to_string).into(),
1100                now_ts().into(),
1101            ])
1102            .to_owned(),
1103    )
1104}
1105
1106/// The most recent audit entries, newest first (id descending, matching insert
1107/// order). Feeds the admin audit view.
1108pub async fn recent_audit(db: &Db, limit: u64) -> Result<Vec<AuditRecord>, AuthError> {
1109    let (sql, values) = build(
1110        db.backend,
1111        Query::select()
1112            .columns([
1113                BackendAuditLog::ActorUserId,
1114                BackendAuditLog::ActorUsername,
1115                BackendAuditLog::Action,
1116                BackendAuditLog::TargetType,
1117                BackendAuditLog::TargetId,
1118                BackendAuditLog::TargetLabel,
1119                BackendAuditLog::Detail,
1120                BackendAuditLog::CreatedAt,
1121            ])
1122            .from(BackendAuditLog::Table)
1123            .order_by(BackendAuditLog::Id, Order::Desc)
1124            .limit(limit)
1125            .to_owned(),
1126    );
1127    let rows = bind_values(sqlx::query(&sql), values)
1128        .fetch_all(&db.pool)
1129        .await?;
1130    rows.iter().map(audit_from_row).collect()
1131}
1132
1133fn audit_from_row(row: &AnyRow) -> Result<AuditRecord, AuthError> {
1134    Ok(AuditRecord {
1135        actor_user_id: row.get_int_opt("actor_user_id")?,
1136        actor_username: row.get_text("actor_username")?,
1137        action: row.get_text("action")?,
1138        target_type: row.get_text_opt("target_type")?,
1139        target_id: row.get_text_opt("target_id")?,
1140        target_label: row.get_text_opt("target_label")?,
1141        detail: row.get_text_opt("detail")?,
1142        created_at: parse_ts(&row.get_text("created_at")?)?,
1143    })
1144}
1145
1146/// Counts recent failed login attempts for a username, for throttling.
1147pub async fn count_recent_failures(
1148    db: &Db,
1149    username: &str,
1150    since: DateTime<Utc>,
1151) -> Result<i64, AuthError> {
1152    let (sql, values) = build(
1153        db.backend,
1154        Query::select()
1155            .expr(Expr::col(BackendAccessLog::Id).count())
1156            .from(BackendAccessLog::Table)
1157            .and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
1158            .and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
1159            .and_where(Expr::col(BackendAccessLog::CreatedAt).gte(ts(since)))
1160            .to_owned(),
1161    );
1162    let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
1163        .fetch_one(&db.pool)
1164        .await?
1165        .0;
1166    Ok(count)
1167}
1168
1169/// Failed sign-ins for `username` from `address` since `since`. Requests that
1170/// carried no address are one bucket of their own.
1171pub async fn count_recent_failures_from(
1172    db: &Db,
1173    username: &str,
1174    address: Option<&str>,
1175    since: DateTime<Utc>,
1176) -> Result<i64, AuthError> {
1177    let (sql, values) = build(
1178        db.backend,
1179        Query::select()
1180            .expr(Expr::col(BackendAccessLog::Id).count())
1181            .from(BackendAccessLog::Table)
1182            .and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
1183            .and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
1184            .and_where(Expr::col(BackendAccessLog::CreatedAt).gte(ts(since)))
1185            .and_where(address_is(address))
1186            .to_owned(),
1187    );
1188    let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
1189        .fetch_one(&db.pool)
1190        .await?
1191        .0;
1192    Ok(count)
1193}
1194
1195/// Failed sign-ins from one address since `since`, whatever the username.
1196pub async fn count_recent_failures_by_address(
1197    db: &Db,
1198    address: Option<&str>,
1199    since: DateTime<Utc>,
1200) -> Result<i64, AuthError> {
1201    let (sql, values) = build(
1202        db.backend,
1203        Query::select()
1204            .expr(Expr::col(BackendAccessLog::Id).count())
1205            .from(BackendAccessLog::Table)
1206            .and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
1207            .and_where(Expr::col(BackendAccessLog::CreatedAt).gte(ts(since)))
1208            .and_where(address_is(address))
1209            .to_owned(),
1210    );
1211    let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
1212        .fetch_one(&db.pool)
1213        .await?
1214        .0;
1215    Ok(count)
1216}
1217
1218/// Whether any one address has reached `max` failed sign-ins for `username`
1219/// since `since`: the account is locked out from there.
1220pub async fn any_address_locked(
1221    db: &Db,
1222    username: &str,
1223    since: DateTime<Utc>,
1224    max: i64,
1225) -> Result<bool, AuthError> {
1226    let (sql, values) = build(
1227        db.backend,
1228        Query::select()
1229            .column(BackendAccessLog::IpAddress)
1230            .from(BackendAccessLog::Table)
1231            .and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
1232            .and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
1233            .and_where(Expr::col(BackendAccessLog::CreatedAt).gte(ts(since)))
1234            .group_by_col(BackendAccessLog::IpAddress)
1235            .and_having(Expr::expr(Expr::col(BackendAccessLog::Id).count()).gte(max))
1236            .limit(1)
1237            .to_owned(),
1238    );
1239    let row = bind_values(sqlx::query(&sql), values)
1240        .fetch_optional(&db.pool)
1241        .await?;
1242    Ok(row.is_some())
1243}
1244
1245/// The address clause: a request with no address matches the rows with none.
1246fn address_is(address: Option<&str>) -> sea_query::SimpleExpr {
1247    match address {
1248        Some(ip) => Expr::col(BackendAccessLog::IpAddress).eq(ip),
1249        None => Expr::col(BackendAccessLog::IpAddress).is_null(),
1250    }
1251}
1252
1253/// Creates a backend user, returning the id the database assigned. Timestamps
1254/// are generated here (no database-side defaults) so the insert is portable.
1255#[allow(clippy::too_many_arguments)]
1256pub async fn create_user(
1257    db: &Db,
1258    username: &str,
1259    email: &str,
1260    first_name: &str,
1261    last_name: Option<&str>,
1262    password_hash: &str,
1263    is_superuser: bool,
1264) -> Result<i64, AuthError> {
1265    let now = now_ts();
1266    let stmt = Query::insert()
1267        .into_table(BackendUsers::Table)
1268        .columns([
1269            BackendUsers::Username,
1270            BackendUsers::Email,
1271            BackendUsers::FirstName,
1272            BackendUsers::LastName,
1273            BackendUsers::PasswordHash,
1274            BackendUsers::IsSuperuser,
1275            BackendUsers::PasswordChangedAt,
1276            BackendUsers::CreatedAt,
1277            BackendUsers::UpdatedAt,
1278        ])
1279        .values_panic([
1280            normalize_key(username).into(),
1281            normalize_key(email).into(),
1282            first_name.into(),
1283            last_name.map(str::to_string).into(),
1284            password_hash.into(),
1285            is_superuser.into(),
1286            now.clone().into(),
1287            now.clone().into(),
1288            now.into(),
1289        ])
1290        .to_owned();
1291    Ok(insert_returning_id(db, stmt, BackendUsers::Id).await?)
1292}
1293
1294/// Whether any backend user exists.
1295pub async fn any_user_exists(db: &Db) -> Result<bool, AuthError> {
1296    let (sql, values) = build(
1297        db.backend,
1298        Query::select()
1299            .expr(Expr::col(BackendUsers::Id).count())
1300            .from(BackendUsers::Table)
1301            .to_owned(),
1302    );
1303    let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
1304        .fetch_one(&db.pool)
1305        .await?
1306        .0;
1307    Ok(count > 0)
1308}
1309
1310/// Sets an operator's own display timezone, or clears it with `None`.
1311pub async fn set_user_timezone(
1312    db: &Db,
1313    user_id: i64,
1314    timezone: Option<&str>,
1315) -> Result<(), AuthError> {
1316    let (sql, values) = build(
1317        db.backend,
1318        Query::update()
1319            .table(BackendUsers::Table)
1320            .value(BackendUsers::Timezone, timezone.map(str::to_string))
1321            .value(BackendUsers::UpdatedAt, now_ts())
1322            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
1323            .to_owned(),
1324    );
1325    bind_values(sqlx::query(&sql), values)
1326        .execute(&db.pool)
1327        .await?;
1328    Ok(())
1329}
1330
1331/// Sets an operator's own UI locale, or clears it with `None`.
1332pub async fn set_user_locale(db: &Db, user_id: i64, locale: Option<&str>) -> Result<(), AuthError> {
1333    let (sql, values) = build(
1334        db.backend,
1335        Query::update()
1336            .table(BackendUsers::Table)
1337            .value(BackendUsers::Locale, locale.map(str::to_string))
1338            .value(BackendUsers::UpdatedAt, now_ts())
1339            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
1340            .to_owned(),
1341    );
1342    bind_values(sqlx::query(&sql), values)
1343        .execute(&db.pool)
1344        .await?;
1345    Ok(())
1346}
1347
1348pub async fn create_role(
1349    db: &Db,
1350    code: &str,
1351    name: &str,
1352    permissions: &[String],
1353) -> Result<i64, AuthError> {
1354    let perms = serde_json::to_string(permissions).unwrap_or_else(|_| "[]".to_string());
1355    let stmt = Query::insert()
1356        .into_table(BackendRoles::Table)
1357        .columns([
1358            BackendRoles::Code,
1359            BackendRoles::Name,
1360            BackendRoles::Permissions,
1361            BackendRoles::CreatedAt,
1362        ])
1363        .values_panic([code.into(), name.into(), perms.into(), now_ts().into()])
1364        .to_owned();
1365    Ok(insert_returning_id(db, stmt, BackendRoles::Id).await?)
1366}
1367
1368/// A role the framework owns: its permissions come from the registry, not from
1369/// the database, so they cannot drift as modules add permissions.
1370pub struct SystemRole<'a> {
1371    pub code: &'a str,
1372    pub name: &'a str,
1373    pub description: &'a str,
1374    pub permissions: Vec<String>,
1375}
1376
1377/// Writes the system roles, creating what is missing and rewriting what exists.
1378///
1379/// Runs at every boot: a module that registers a new permission has it in the
1380/// right role the moment it loads, and an operator cannot leave a system role
1381/// holding a permission the registry no longer declares. Only the framework's
1382/// own rows are touched; a role an operator made is never rewritten.
1383pub async fn sync_system_roles(db: &Db, roles: &[SystemRole<'_>]) -> Result<(), AuthError> {
1384    for role in roles {
1385        let perms = serde_json::to_string(&role.permissions).unwrap_or_else(|_| "[]".to_string());
1386        // Insert-then-update rather than select-then-insert: two instances booting
1387        // together both reach the insert, and the loser is ignored instead of
1388        // failing the unique key and taking its boot down with it.
1389        let (sql, values) = build(
1390            db.backend,
1391            Query::insert()
1392                .into_table(BackendRoles::Table)
1393                .columns([
1394                    BackendRoles::Code,
1395                    BackendRoles::Name,
1396                    BackendRoles::Description,
1397                    BackendRoles::Permissions,
1398                    BackendRoles::IsSystem,
1399                    BackendRoles::CreatedAt,
1400                ])
1401                .values_panic([
1402                    role.code.into(),
1403                    role.name.into(),
1404                    role.description.into(),
1405                    perms.clone().into(),
1406                    true.into(),
1407                    now_ts().into(),
1408                ])
1409                .on_conflict(on_conflict_ignore([BackendRoles::Code]))
1410                .to_owned(),
1411        );
1412        bind_values(sqlx::query(&sql), values)
1413            .execute(&db.pool)
1414            .await?;
1415        let (sql, values) = build(
1416            db.backend,
1417            Query::update()
1418                .table(BackendRoles::Table)
1419                .values([
1420                    (BackendRoles::Name, role.name.into()),
1421                    (BackendRoles::Description, role.description.into()),
1422                    (BackendRoles::Permissions, perms.into()),
1423                    (BackendRoles::IsSystem, true.into()),
1424                ])
1425                .and_where(Expr::col(BackendRoles::Code).eq(role.code))
1426                .to_owned(),
1427        );
1428        bind_values(sqlx::query(&sql), values)
1429            .execute(&db.pool)
1430            .await?;
1431    }
1432    Ok(())
1433}
1434
1435/// The id of the role with this code, if there is one.
1436pub async fn role_id_by_code(db: &Db, code: &str) -> Result<Option<i64>, AuthError> {
1437    let (sql, values) = build(
1438        db.backend,
1439        Query::select()
1440            .column(BackendRoles::Id)
1441            .from(BackendRoles::Table)
1442            .and_where(Expr::col(BackendRoles::Code).eq(code))
1443            .to_owned(),
1444    );
1445    let row = bind_values(sqlx::query(&sql), values)
1446        .fetch_optional(&db.pool)
1447        .await?;
1448    Ok(match row {
1449        Some(row) => Some(row.try_get::<i64, _>("id")?),
1450        None => None,
1451    })
1452}
1453
1454/// The permission codes this role grants.
1455pub async fn role_permissions(db: &Db, id: i64) -> Result<Vec<String>, AuthError> {
1456    let (sql, values) = build(
1457        db.backend,
1458        Query::select()
1459            .column(BackendRoles::Permissions)
1460            .from(BackendRoles::Table)
1461            .and_where(Expr::col(BackendRoles::Id).eq(id))
1462            .to_owned(),
1463    );
1464    let row = bind_values(sqlx::query(&sql), values)
1465        .fetch_optional(&db.pool)
1466        .await?;
1467    Ok(match row {
1468        Some(row) => serde_json::from_str(&row.get_text("permissions")?).unwrap_or_default(),
1469        None => Vec::new(),
1470    })
1471}
1472
1473/// Whether this role is one the framework owns.
1474pub async fn role_is_system(db: &Db, id: i64) -> Result<bool, AuthError> {
1475    let (sql, values) = is_system_query(db.backend, id);
1476    let row = bind_values(sqlx::query(&sql), values)
1477        .fetch_optional(&db.pool)
1478        .await?;
1479    read_is_system(row)
1480}
1481
1482/// The same question asked on an open transaction, for a listener that must see
1483/// the state the write is running against.
1484pub async fn role_is_system_on(
1485    conn: &mut sqlx::AnyConnection,
1486    backend: laterite_core::DbBackend,
1487    id: i64,
1488) -> Result<bool, AuthError> {
1489    let (sql, values) = is_system_query(backend, id);
1490    let row = bind_values(sqlx::query(&sql), values)
1491        .fetch_optional(&mut *conn)
1492        .await?;
1493    read_is_system(row)
1494}
1495
1496fn is_system_query(backend: laterite_core::DbBackend, id: i64) -> (String, sea_query::Values) {
1497    build(
1498        backend,
1499        Query::select()
1500            .column(BackendRoles::IsSystem)
1501            .from(BackendRoles::Table)
1502            .and_where(Expr::col(BackendRoles::Id).eq(id))
1503            .to_owned(),
1504    )
1505}
1506
1507/// A missing row is not a system role: it is nothing at all.
1508fn read_is_system(row: Option<AnyRow>) -> Result<bool, AuthError> {
1509    Ok(match row {
1510        Some(row) => row.get_bool("is_system")?,
1511        None => false,
1512    })
1513}
1514
1515/// Rewrites renamed permission codes wherever an operator's choices hold them.
1516///
1517/// A system role is rebuilt from the registry at every boot, so it needs no
1518/// repair. A role someone made, and a per-user override, hold whatever code was
1519/// current when they were saved: without this they keep granting a string
1520/// nothing checks any more. Returns how many rows changed, so a boot can say so
1521/// once and stay quiet afterwards.
1522///
1523/// Idempotent: a second run finds no old code and writes nothing.
1524pub async fn rename_permissions(db: &Db, renames: &[(String, String)]) -> Result<usize, AuthError> {
1525    if renames.is_empty() {
1526        return Ok(0);
1527    }
1528    let mut changed = 0;
1529
1530    // Roles hold a JSON array of codes.
1531    let (sql, values) = build(
1532        db.backend,
1533        Query::select()
1534            .columns([BackendRoles::Id, BackendRoles::Permissions])
1535            .from(BackendRoles::Table)
1536            .to_owned(),
1537    );
1538    for row in bind_values(sqlx::query(&sql), values)
1539        .fetch_all(&db.pool)
1540        .await?
1541    {
1542        let id = row.try_get::<i64, _>("id")?;
1543        let held: Vec<String> =
1544            serde_json::from_str(&row.get_text("permissions")?).unwrap_or_default();
1545        let mut next: Vec<String> = held
1546            .iter()
1547            .map(|code| apply_rename(code, renames))
1548            .collect();
1549        // A rename can land on a code the role already held.
1550        next.dedup_by(|a, b| a == b);
1551        let mut seen = std::collections::HashSet::new();
1552        next.retain(|code| seen.insert(code.clone()));
1553        if next != held {
1554            let json = serde_json::to_string(&next).unwrap_or_else(|_| "[]".to_string());
1555            let (sql, values) = build(
1556                db.backend,
1557                Query::update()
1558                    .table(BackendRoles::Table)
1559                    .value(BackendRoles::Permissions, json)
1560                    .and_where(Expr::col(BackendRoles::Id).eq(id))
1561                    .to_owned(),
1562            );
1563            bind_values(sqlx::query(&sql), values)
1564                .execute(&db.pool)
1565                .await?;
1566            changed += 1;
1567        }
1568    }
1569
1570    // A user's overrides are a JSON object keyed by code.
1571    let (sql, values) = build(
1572        db.backend,
1573        Query::select()
1574            .columns([BackendUsers::Id, BackendUsers::Permissions])
1575            .from(BackendUsers::Table)
1576            .to_owned(),
1577    );
1578    for row in bind_values(sqlx::query(&sql), values)
1579        .fetch_all(&db.pool)
1580        .await?
1581    {
1582        let id = row.try_get::<i64, _>("id")?;
1583        let held: HashMap<String, i64> =
1584            serde_json::from_str(&row.get_text("permissions")?).unwrap_or_default();
1585        let next: HashMap<String, i64> = held
1586            .iter()
1587            .map(|(code, state)| (apply_rename(code, renames), *state))
1588            .collect();
1589        if next != held {
1590            let json = serde_json::to_string(&next).unwrap_or_else(|_| "{}".to_string());
1591            let (sql, values) = build(
1592                db.backend,
1593                Query::update()
1594                    .table(BackendUsers::Table)
1595                    .value(BackendUsers::Permissions, json)
1596                    .and_where(Expr::col(BackendUsers::Id).eq(id))
1597                    .to_owned(),
1598            );
1599            bind_values(sqlx::query(&sql), values)
1600                .execute(&db.pool)
1601                .await?;
1602            changed += 1;
1603        }
1604    }
1605    Ok(changed)
1606}
1607
1608/// The code this one became, or itself.
1609fn apply_rename(code: &str, renames: &[(String, String)]) -> String {
1610    renames
1611        .iter()
1612        .find(|(old, _)| old == code)
1613        .map(|(_, new)| new.clone())
1614        .unwrap_or_else(|| code.to_string())
1615}
1616
1617/// A role as the assignment screen lists it.
1618pub struct RoleSummary {
1619    pub id: i64,
1620    pub code: String,
1621    pub name: String,
1622    pub is_system: bool,
1623    /// What holding it grants, so a screen can refuse to offer a role that
1624    /// would grant more than the operator assigning it holds.
1625    pub permissions: Vec<String>,
1626}
1627
1628/// Every role, by name.
1629pub async fn list_roles(db: &Db) -> Result<Vec<RoleSummary>, AuthError> {
1630    let (sql, values) = build(
1631        db.backend,
1632        Query::select()
1633            .columns([
1634                BackendRoles::Id,
1635                BackendRoles::Code,
1636                BackendRoles::Name,
1637                BackendRoles::IsSystem,
1638                BackendRoles::Permissions,
1639            ])
1640            .from(BackendRoles::Table)
1641            .order_by(BackendRoles::Name, Order::Asc)
1642            .to_owned(),
1643    );
1644    let rows = bind_values(sqlx::query(&sql), values)
1645        .fetch_all(&db.pool)
1646        .await?;
1647    rows.into_iter()
1648        .map(|row| {
1649            Ok(RoleSummary {
1650                id: row.try_get::<i64, _>("id")?,
1651                code: row.get_text("code")?,
1652                name: row.get_text("name")?,
1653                is_system: row.get_bool("is_system")?,
1654                permissions: serde_json::from_str(&row.get_text("permissions")?)
1655                    .unwrap_or_default(),
1656            })
1657        })
1658        .collect()
1659}
1660
1661/// The roles this operator holds.
1662pub async fn user_role_ids(db: &Db, user_id: i64) -> Result<Vec<i64>, AuthError> {
1663    let (sql, values) = build(
1664        db.backend,
1665        Query::select()
1666            .column(BackendUserRoles::BackendRoleId)
1667            .from(BackendUserRoles::Table)
1668            .and_where(Expr::col(BackendUserRoles::BackendUserId).eq(user_id))
1669            .to_owned(),
1670    );
1671    let rows = bind_values(sqlx::query(&sql), values)
1672        .fetch_all(&db.pool)
1673        .await?;
1674    rows.into_iter()
1675        .map(|row| Ok(row.try_get::<i64, _>("backend_role_id")?))
1676        .collect()
1677}
1678
1679/// Replaces this operator's roles with exactly `role_ids`.
1680///
1681/// One transaction, so an operator is never briefly left holding none of them.
1682pub async fn set_user_roles(db: &Db, user_id: i64, role_ids: &[i64]) -> Result<(), AuthError> {
1683    let mut tx = db.pool.begin().await?;
1684    let (sql, values) = build(
1685        db.backend,
1686        Query::delete()
1687            .from_table(BackendUserRoles::Table)
1688            .and_where(Expr::col(BackendUserRoles::BackendUserId).eq(user_id))
1689            .to_owned(),
1690    );
1691    bind_values(sqlx::query(&sql), values)
1692        .execute(&mut *tx)
1693        .await?;
1694    for role_id in role_ids {
1695        let (sql, values) = build(
1696            db.backend,
1697            Query::insert()
1698                .into_table(BackendUserRoles::Table)
1699                .columns([
1700                    BackendUserRoles::BackendUserId,
1701                    BackendUserRoles::BackendRoleId,
1702                ])
1703                .values_panic([user_id.into(), (*role_id).into()])
1704                .on_conflict(on_conflict_ignore([
1705                    BackendUserRoles::BackendUserId,
1706                    BackendUserRoles::BackendRoleId,
1707                ]))
1708                .to_owned(),
1709        );
1710        bind_values(sqlx::query(&sql), values)
1711            .execute(&mut *tx)
1712            .await?;
1713    }
1714    tx.commit().await?;
1715    Ok(())
1716}
1717
1718pub async fn assign_role(db: &Db, user_id: i64, role_id: i64) -> Result<(), AuthError> {
1719    let (sql, values) = build(
1720        db.backend,
1721        Query::insert()
1722            .into_table(BackendUserRoles::Table)
1723            .columns([
1724                BackendUserRoles::BackendUserId,
1725                BackendUserRoles::BackendRoleId,
1726            ])
1727            .values_panic([user_id.into(), role_id.into()])
1728            .on_conflict(on_conflict_ignore([
1729                BackendUserRoles::BackendUserId,
1730                BackendUserRoles::BackendRoleId,
1731            ]))
1732            .to_owned(),
1733    );
1734    bind_values(sqlx::query(&sql), values)
1735        .execute(&db.pool)
1736        .await?;
1737    Ok(())
1738}
1739
1740/// Sets whether a user may sign in. Returns rows affected, so a caller can tell
1741/// a no-op from a missing user.
1742/// Sets whether an account may sign in. Deactivating also ends every session
1743/// and stay-signed-in credential with [`RevokeReason::Deactivated`], in the same
1744/// transaction, so the account is never blocked from signing in while still
1745/// signed in somewhere.
1746pub(crate) async fn set_user_active(db: &Db, user_id: i64, active: bool) -> Result<u64, AuthError> {
1747    let mut tx = db.pool.begin().await?;
1748    let (sql, values) = build(
1749        db.backend,
1750        Query::update()
1751            .table(BackendUsers::Table)
1752            .value(BackendUsers::IsActive, active)
1753            .and_where(Expr::col(BackendUsers::Id).eq(user_id))
1754            .to_owned(),
1755    );
1756    let done = bind_values(sqlx::query(&sql), values)
1757        .execute(&mut *tx)
1758        .await?
1759        .rows_affected();
1760    if done > 0 && !active {
1761        let (sql, values) =
1762            revoke_user_sessions_query(db.backend, user_id, None, RevokeReason::Deactivated);
1763        bind_values(sqlx::query(&sql), values)
1764            .execute(&mut *tx)
1765            .await?;
1766        let (sql, values) =
1767            revoke_user_remember_tokens_query(db.backend, user_id, RevokeReason::Deactivated);
1768        bind_values(sqlx::query(&sql), values)
1769            .execute(&mut *tx)
1770            .await?;
1771    }
1772    tx.commit().await?;
1773    Ok(done)
1774}
1775
1776/// How many active superusers there are besides `excluding`.
1777///
1778/// Deactivating the last one would leave a panel nobody can fully administer,
1779/// recoverable only from the command line, so the caller refuses it.
1780pub(crate) async fn other_active_superusers(db: &Db, excluding: i64) -> Result<i64, AuthError> {
1781    let (sql, values) = build(
1782        db.backend,
1783        Query::select()
1784            .expr(Expr::col(BackendUsers::Id).count())
1785            .from(BackendUsers::Table)
1786            .and_where(Expr::col(BackendUsers::IsSuperuser).eq(true))
1787            .and_where(Expr::col(BackendUsers::IsActive).eq(true))
1788            .and_where(Expr::col(BackendUsers::Id).ne(excluding))
1789            .to_owned(),
1790    );
1791    let row = bind_values(sqlx::query(&sql), values)
1792        .fetch_one(&db.pool)
1793        .await?;
1794    Ok(row.try_get::<i64, _>(0).unwrap_or(0))
1795}
1796
1797/// Lists backend users for admin tooling, ordered by creation time.
1798pub async fn list_backend_users(db: &Db) -> Result<Vec<BackendUserSummary>, AuthError> {
1799    let (sql, values) = build(
1800        db.backend,
1801        Query::select()
1802            .columns([
1803                BackendUsers::Id,
1804                BackendUsers::Username,
1805                BackendUsers::Email,
1806                BackendUsers::FirstName,
1807                BackendUsers::LastName,
1808                BackendUsers::IsSuperuser,
1809                BackendUsers::IsActive,
1810                BackendUsers::CreatedAt,
1811            ])
1812            .from(BackendUsers::Table)
1813            .order_by(BackendUsers::CreatedAt, Order::Asc)
1814            .to_owned(),
1815    );
1816    let rows = bind_values(sqlx::query(&sql), values)
1817        .fetch_all(&db.pool)
1818        .await?;
1819    rows.iter().map(summary_from_row).collect()
1820}
1821
1822/// Sets a new password hash for a user by username, returning rows affected.
1823pub async fn update_password_by_username(
1824    db: &Db,
1825    username: &str,
1826    password_hash: &str,
1827) -> Result<u64, AuthError> {
1828    let (sql, values) = build(
1829        db.backend,
1830        Query::update()
1831            .table(BackendUsers::Table)
1832            .value(BackendUsers::PasswordHash, password_hash)
1833            .value(BackendUsers::PasswordChangedAt, now_ts())
1834            .value(BackendUsers::UpdatedAt, now_ts())
1835            .and_where(Expr::col(BackendUsers::Username).eq(normalize_key(username)))
1836            .to_owned(),
1837    );
1838    let result = bind_values(sqlx::query(&sql), values)
1839        .execute(&db.pool)
1840        .await?;
1841    Ok(result.rows_affected())
1842}
1843
1844/// Clears a user's failed-login records, releasing a lockout.
1845pub async fn clear_failed_attempts(db: &Db, username: &str) -> Result<u64, AuthError> {
1846    let (sql, values) = build(
1847        db.backend,
1848        Query::delete()
1849            .from_table(BackendAccessLog::Table)
1850            .and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
1851            .and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
1852            .to_owned(),
1853    );
1854    let result = bind_values(sqlx::query(&sql), values)
1855        .execute(&db.pool)
1856        .await?;
1857    Ok(result.rows_affected())
1858}
1859
1860/// One operator's stored choice for `key`, or `None` if they have not made one.
1861pub async fn user_preference(
1862    db: &Db,
1863    user_id: i64,
1864    key: &str,
1865) -> Result<Option<String>, AuthError> {
1866    let (sql, values) = build(
1867        db.backend,
1868        Query::select()
1869            .column(BackendUserPreferences::Value)
1870            .from(BackendUserPreferences::Table)
1871            .and_where(Expr::col(BackendUserPreferences::UserId).eq(user_id))
1872            .and_where(Expr::col(BackendUserPreferences::PreferenceKey).eq(key))
1873            .to_owned(),
1874    );
1875    let row = bind_values(sqlx::query(&sql), values)
1876        .fetch_optional(&db.pool)
1877        .await?;
1878    Ok(row.and_then(|r| r.get_text_opt("value").ok().flatten()))
1879}
1880
1881/// Stores one operator's choice for `key`, replacing any previous one.
1882///
1883/// Written as delete-then-insert rather than an upsert: the three backends spell
1884/// `ON CONFLICT` differently, and a preference write is rare and already inside
1885/// its own request, so the portable pair costs nothing worth optimising.
1886pub async fn set_user_preference(
1887    db: &Db,
1888    user_id: i64,
1889    key: &str,
1890    value: &str,
1891) -> Result<(), AuthError> {
1892    let mut tx = db.pool.begin().await?;
1893    let (sql, values) = build(
1894        db.backend,
1895        Query::delete()
1896            .from_table(BackendUserPreferences::Table)
1897            .and_where(Expr::col(BackendUserPreferences::UserId).eq(user_id))
1898            .and_where(Expr::col(BackendUserPreferences::PreferenceKey).eq(key))
1899            .to_owned(),
1900    );
1901    bind_values(sqlx::query(&sql), values)
1902        .execute(&mut *tx)
1903        .await?;
1904    let (sql, values) = build(
1905        db.backend,
1906        Query::insert()
1907            .into_table(BackendUserPreferences::Table)
1908            .columns([
1909                BackendUserPreferences::UserId,
1910                BackendUserPreferences::PreferenceKey,
1911                BackendUserPreferences::Value,
1912            ])
1913            .values_panic([
1914                user_id.into(),
1915                key.to_string().into(),
1916                value.to_string().into(),
1917            ])
1918            .to_owned(),
1919    );
1920    bind_values(sqlx::query(&sql), values)
1921        .execute(&mut *tx)
1922        .await?;
1923    tx.commit().await?;
1924    Ok(())
1925}
1926
1927/// Removes one operator's choice for `key`, so they fall back to the default.
1928pub async fn clear_user_preference(db: &Db, user_id: i64, key: &str) -> Result<(), AuthError> {
1929    let (sql, values) = build(
1930        db.backend,
1931        Query::delete()
1932            .from_table(BackendUserPreferences::Table)
1933            .and_where(Expr::col(BackendUserPreferences::UserId).eq(user_id))
1934            .and_where(Expr::col(BackendUserPreferences::PreferenceKey).eq(key))
1935            .to_owned(),
1936    );
1937    bind_values(sqlx::query(&sql), values)
1938        .execute(&db.pool)
1939        .await?;
1940    Ok(())
1941}
1942
1943#[cfg(test)]
1944mod tests {
1945    use super::*;
1946    use laterite_core::testing::connect_test;
1947
1948    async fn operator(db: &Db) -> i64 {
1949        let svc = crate::AuthService::new(db.clone(), crate::AuthConfig::default());
1950        svc.create_superuser(crate::NewOperator {
1951            username: "root",
1952            email: "root@acme.test",
1953            first_name: "Root",
1954            last_name: None,
1955            password: "rootpw12345",
1956            timezone: None,
1957        })
1958        .await
1959        .unwrap()
1960    }
1961
1962    #[tokio::test]
1963    async fn a_preference_round_trips_and_replaces() {
1964        let (db, _guard) = connect_test(&[crate::migrations()]).await;
1965        let id = operator(&db).await;
1966
1967        // Unset reads as absent, not as an empty value.
1968        assert_eq!(
1969            user_preference(&db, id, "list.columns./roles")
1970                .await
1971                .unwrap(),
1972            None
1973        );
1974
1975        set_user_preference(&db, id, "list.columns./roles", "code,name")
1976            .await
1977            .unwrap();
1978        assert_eq!(
1979            user_preference(&db, id, "list.columns./roles")
1980                .await
1981                .unwrap(),
1982            Some("code,name".to_string())
1983        );
1984
1985        // A second write replaces rather than accumulating, which the unique
1986        // index would otherwise refuse.
1987        set_user_preference(&db, id, "list.columns./roles", "code")
1988            .await
1989            .unwrap();
1990        assert_eq!(
1991            user_preference(&db, id, "list.columns./roles")
1992                .await
1993                .unwrap(),
1994            Some("code".to_string())
1995        );
1996
1997        clear_user_preference(&db, id, "list.columns./roles")
1998            .await
1999            .unwrap();
2000        assert_eq!(
2001            user_preference(&db, id, "list.columns./roles")
2002                .await
2003                .unwrap(),
2004            None
2005        );
2006    }
2007
2008    /// Preferences are per operator and per key: one does not leak into another.
2009    #[tokio::test]
2010    async fn preferences_are_scoped() {
2011        let (db, _guard) = connect_test(&[crate::migrations()]).await;
2012        let root = operator(&db).await;
2013        let hash = crate::password::hash_password("otherpw12345").unwrap();
2014        let other = create_user(&db, "other", "other@acme.test", "Other", None, &hash, false)
2015            .await
2016            .unwrap();
2017
2018        set_user_preference(&db, root, "list.columns./roles", "code")
2019            .await
2020            .unwrap();
2021        assert_eq!(
2022            user_preference(&db, other, "list.columns./roles")
2023                .await
2024                .unwrap(),
2025            None
2026        );
2027        assert_eq!(
2028            user_preference(&db, root, "list.columns./users")
2029                .await
2030                .unwrap(),
2031            None
2032        );
2033    }
2034}