use std::collections::HashMap;
use chrono::{DateTime, SecondsFormat, Utc};
use laterite_core::query::{
bind_values, bind_values_as, build, insert_returning_id, on_conflict_ignore,
};
use laterite_core::{AnyRowExt, Db};
use sea_query::{Expr, Order, Query};
use sqlx::any::AnyRow;
use sqlx::Row;
use crate::error::AuthError;
use crate::models::{AccessEvent, BackendUser, BackendUserSummary};
use crate::schema::{
BackendAccessLog, BackendAuditLog, BackendRememberTokens, BackendRoles, BackendSessions,
BackendUserPreferences, BackendUserRoles, BackendUsers,
};
fn now_ts() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true)
}
fn ts(dt: DateTime<Utc>) -> String {
dt.to_rfc3339_opts(SecondsFormat::Micros, true)
}
fn parse_ts(s: &str) -> Result<DateTime<Utc>, AuthError> {
DateTime::parse_from_rfc3339(s)
.map(|d| d.with_timezone(&Utc))
.map_err(|e| AuthError::Data(format!("timestamp `{s}`: {e}")))
}
fn normalize_key(s: &str) -> String {
s.trim().to_lowercase()
}
fn user_from_row(row: &AnyRow) -> Result<BackendUser, AuthError> {
Ok(BackendUser {
id: row.try_get::<i64, _>("id")?,
username: row.get_text("username")?,
email: row.get_text("email")?,
first_name: row.get_text("first_name")?,
last_name: row.get_text_opt("last_name")?,
password_hash: row.get_text("password_hash")?,
is_superuser: row.get_bool("is_superuser")?,
is_active: row.get_bool("is_active")?,
timezone: row.get_text_opt("timezone")?,
locale: row.get_text_opt("locale")?,
created_at: parse_ts(&row.get_text("created_at")?)?,
updated_at: parse_ts(&row.get_text("updated_at")?)?,
})
}
fn summary_from_row(row: &AnyRow) -> Result<BackendUserSummary, AuthError> {
Ok(BackendUserSummary {
id: row.try_get::<i64, _>("id")?,
username: row.get_text("username")?,
email: row.get_text("email")?,
first_name: row.get_text("first_name")?,
last_name: row.get_text_opt("last_name")?,
is_superuser: row.get_bool("is_superuser")?,
is_active: row.get_bool("is_active")?,
created_at: parse_ts(&row.get_text("created_at")?)?,
})
}
const USER_COLS: [BackendUsers; 12] = [
BackendUsers::Id,
BackendUsers::Username,
BackendUsers::Email,
BackendUsers::FirstName,
BackendUsers::LastName,
BackendUsers::PasswordHash,
BackendUsers::IsSuperuser,
BackendUsers::IsActive,
BackendUsers::Timezone,
BackendUsers::Locale,
BackendUsers::CreatedAt,
BackendUsers::UpdatedAt,
];
pub async fn find_user_by_username(
db: &Db,
username: &str,
) -> Result<Option<BackendUser>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns(USER_COLS)
.from(BackendUsers::Table)
.and_where(Expr::col(BackendUsers::Username).eq(normalize_key(username)))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
row.map(|r| user_from_row(&r)).transpose()
}
pub async fn find_user_by_id(db: &Db, id: i64) -> Result<Option<BackendUser>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns(USER_COLS)
.from(BackendUsers::Table)
.and_where(Expr::col(BackendUsers::Id).eq(id))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
row.map(|r| user_from_row(&r)).transpose()
}
pub async fn find_active_user_by_id(db: &Db, id: i64) -> Result<Option<BackendUser>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns(USER_COLS)
.from(BackendUsers::Table)
.and_where(Expr::col(BackendUsers::Id).eq(id))
.and_where(Expr::col(BackendUsers::IsActive).eq(true))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
row.map(|r| user_from_row(&r)).transpose()
}
pub async fn load_role_permissions(db: &Db, user_id: i64) -> Result<Vec<Vec<String>>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.column((BackendRoles::Table, BackendRoles::Permissions))
.from(BackendUserRoles::Table)
.inner_join(
BackendRoles::Table,
Expr::col((BackendRoles::Table, BackendRoles::Id))
.equals((BackendUserRoles::Table, BackendUserRoles::BackendRoleId)),
)
.and_where(
Expr::col((BackendUserRoles::Table, BackendUserRoles::BackendUserId)).eq(user_id),
)
.to_owned(),
);
let rows = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let json = row.get_text("permissions")?;
let perms: Vec<String> = serde_json::from_str(&json)
.map_err(|e| AuthError::Data(format!("role permissions: {e}")))?;
out.push(perms);
}
Ok(out)
}
pub async fn load_user_permission_overrides(
db: &Db,
user_id: i64,
) -> Result<HashMap<String, i64>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.column(BackendUsers::Permissions)
.from(BackendUsers::Table)
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
let overrides = match row {
Some(r) => {
let json = r.get_text("permissions")?;
serde_json::from_str(&json).unwrap_or_default()
}
None => HashMap::new(),
};
Ok(overrides)
}
pub async fn set_user_permissions(
db: &Db,
user_id: i64,
overrides: &HashMap<String, i64>,
) -> Result<(), AuthError> {
let json = serde_json::to_string(overrides).unwrap_or_else(|_| "{}".to_string());
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendUsers::Table)
.value(BackendUsers::Permissions, json)
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub(crate) async fn insert_session(
db: &Db,
token_hash: &str,
user_id: i64,
expires_at: DateTime<Utc>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<(), AuthError> {
let now = now_ts();
let (sql, values) = build(
db.backend,
Query::insert()
.into_table(BackendSessions::Table)
.columns([
BackendSessions::TokenHash,
BackendSessions::BackendUserId,
BackendSessions::CreatedAt,
BackendSessions::LastSeenAt,
BackendSessions::ExpiresAt,
BackendSessions::IpAddress,
BackendSessions::UserAgent,
])
.values_panic([
token_hash.into(),
user_id.into(),
now.clone().into(),
now.into(),
ts(expires_at).into(),
ip_address.into(),
user_agent.into(),
])
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub struct ValidSession {
pub user_id: i64,
pub data: Option<String>,
pub created_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
}
pub(crate) async fn find_valid_session(
db: &Db,
token_hash: &str,
now: DateTime<Utc>,
) -> Result<Option<ValidSession>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([
BackendSessions::BackendUserId,
BackendSessions::Data,
BackendSessions::CreatedAt,
BackendSessions::LastSeenAt,
])
.from(BackendSessions::Table)
.and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
.and_where(Expr::col(BackendSessions::ExpiresAt).gt(ts(now)))
.and_where(Expr::col(BackendSessions::RevokedReason).is_null())
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
match row {
Some(r) => Ok(Some(ValidSession {
user_id: r.try_get::<i64, _>("backend_user_id")?,
data: r.get_text_opt("data")?,
created_at: parse_ts(&r.get_text("created_at")?)?,
last_seen_at: parse_ts(&r.get_text("last_seen_at")?)?,
})),
None => Ok(None),
}
}
pub(crate) async fn set_session_data(
db: &Db,
token_hash: &str,
data: &str,
) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendSessions::Table)
.value(BackendSessions::Data, data)
.and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub(crate) async fn renew_session(
db: &Db,
token_hash: &str,
now: DateTime<Utc>,
expires_at: DateTime<Utc>,
) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendSessions::Table)
.value(BackendSessions::LastSeenAt, ts(now))
.value(BackendSessions::ExpiresAt, ts(expires_at))
.and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub(crate) struct RememberToken {
pub user_id: i64,
pub verifier_hash: String,
}
pub(crate) async fn insert_remember_token(
db: &Db,
selector: &str,
verifier_hash: &str,
user_id: i64,
expires_at: DateTime<Utc>,
) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::insert()
.into_table(BackendRememberTokens::Table)
.columns([
BackendRememberTokens::Selector,
BackendRememberTokens::VerifierHash,
BackendRememberTokens::BackendUserId,
BackendRememberTokens::CreatedAt,
BackendRememberTokens::ExpiresAt,
])
.values_panic([
selector.into(),
verifier_hash.into(),
user_id.into(),
now_ts().into(),
ts(expires_at).into(),
])
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub(crate) async fn find_remember_token(
db: &Db,
selector: &str,
now: DateTime<Utc>,
) -> Result<Option<RememberToken>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([
BackendRememberTokens::BackendUserId,
BackendRememberTokens::VerifierHash,
])
.from(BackendRememberTokens::Table)
.and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
.and_where(Expr::col(BackendRememberTokens::ExpiresAt).gt(ts(now)))
.and_where(Expr::col(BackendRememberTokens::RevokedReason).is_null())
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
match row {
Some(r) => Ok(Some(RememberToken {
user_id: r.try_get::<i64, _>("backend_user_id")?,
verifier_hash: r.get_text("verifier_hash")?,
})),
None => Ok(None),
}
}
pub(crate) async fn rotate_remember_token(
db: &Db,
selector: &str,
verifier_hash: &str,
expires_at: DateTime<Utc>,
) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendRememberTokens::Table)
.value(BackendRememberTokens::VerifierHash, verifier_hash)
.value(BackendRememberTokens::ExpiresAt, ts(expires_at))
.and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub(crate) async fn delete_remember_token(db: &Db, selector: &str) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(BackendRememberTokens::Table)
.and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub(crate) async fn revoke_user_remember_tokens(
db: &Db,
user_id: i64,
reason: RevokeReason,
) -> Result<(), AuthError> {
let (sql, values) = revoke_user_remember_tokens_query(db.backend, user_id, reason);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
fn revoke_user_remember_tokens_query(
backend: laterite_core::DbBackend,
user_id: i64,
reason: RevokeReason,
) -> (String, sea_query::Values) {
build(
backend,
Query::update()
.table(BackendRememberTokens::Table)
.value(BackendRememberTokens::RevokedReason, reason.as_str())
.and_where(Expr::col(BackendRememberTokens::BackendUserId).eq(user_id))
.and_where(Expr::col(BackendRememberTokens::RevokedReason).is_null())
.to_owned(),
)
}
pub(crate) async fn revoked_remember_token(
db: &Db,
selector: &str,
) -> Result<Option<(String, RevokeReason)>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([
BackendRememberTokens::VerifierHash,
BackendRememberTokens::RevokedReason,
])
.from(BackendRememberTokens::Table)
.and_where(Expr::col(BackendRememberTokens::Selector).eq(selector))
.and_where(Expr::col(BackendRememberTokens::RevokedReason).is_not_null())
.to_owned(),
);
let Some(row) = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?
else {
return Ok(None);
};
let reason = row
.get_text_opt("revoked_reason")?
.as_deref()
.and_then(RevokeReason::parse);
Ok(reason.map(|r| (row.get_text("verifier_hash").unwrap_or_default(), r)))
}
pub(crate) async fn delete_user_remember_tokens(db: &Db, user_id: i64) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(BackendRememberTokens::Table)
.and_where(Expr::col(BackendRememberTokens::BackendUserId).eq(user_id))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub(crate) struct SessionSummary {
pub token_hash: String,
pub created_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
pub(crate) async fn list_user_sessions(
db: &Db,
user_id: i64,
now: DateTime<Utc>,
) -> Result<Vec<SessionSummary>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([
BackendSessions::TokenHash,
BackendSessions::CreatedAt,
BackendSessions::LastSeenAt,
BackendSessions::ExpiresAt,
BackendSessions::IpAddress,
BackendSessions::UserAgent,
])
.from(BackendSessions::Table)
.and_where(Expr::col(BackendSessions::BackendUserId).eq(user_id))
.and_where(Expr::col(BackendSessions::ExpiresAt).gt(ts(now)))
.and_where(Expr::col(BackendSessions::RevokedReason).is_null())
.order_by(BackendSessions::LastSeenAt, Order::Desc)
.to_owned(),
);
let rows = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
rows.iter()
.map(|r| {
Ok(SessionSummary {
token_hash: r.get_text("token_hash")?,
created_at: parse_ts(&r.get_text("created_at")?)?,
last_seen_at: parse_ts(&r.get_text("last_seen_at")?)?,
expires_at: parse_ts(&r.get_text("expires_at")?)?,
ip_address: r.get_text_opt("ip_address")?,
user_agent: r.get_text_opt("user_agent")?,
})
})
.collect()
}
pub(crate) async fn revoke_user_session(
db: &Db,
user_id: i64,
token_hash: &str,
reason: RevokeReason,
) -> Result<u64, AuthError> {
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendSessions::Table)
.value(BackendSessions::RevokedReason, reason.as_str())
.and_where(Expr::col(BackendSessions::BackendUserId).eq(user_id))
.and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
.and_where(Expr::col(BackendSessions::RevokedReason).is_null())
.to_owned(),
);
let done = bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(done.rows_affected())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RevokeReason {
PasswordChanged,
Deactivated,
SignedOutElsewhere,
}
impl RevokeReason {
pub fn as_str(self) -> &'static str {
match self {
RevokeReason::PasswordChanged => "password_changed",
RevokeReason::Deactivated => "deactivated",
RevokeReason::SignedOutElsewhere => "signed_out_elsewhere",
}
}
pub fn parse(code: &str) -> Option<Self> {
match code {
"password_changed" => Some(RevokeReason::PasswordChanged),
"deactivated" => Some(RevokeReason::Deactivated),
"signed_out_elsewhere" => Some(RevokeReason::SignedOutElsewhere),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SessionEnd {
Expired,
Revoked(RevokeReason),
}
impl SessionEnd {
pub fn code(self) -> &'static str {
match self {
SessionEnd::Expired => "expired",
SessionEnd::Revoked(reason) => reason.as_str(),
}
}
}
pub(crate) async fn revoke_user_sessions(
db: &Db,
user_id: i64,
keep: Option<&str>,
reason: RevokeReason,
) -> Result<u64, AuthError> {
let (sql, values) = revoke_user_sessions_query(db.backend, user_id, keep, reason);
let done = bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(done.rows_affected())
}
fn revoke_user_sessions_query(
backend: laterite_core::DbBackend,
user_id: i64,
keep: Option<&str>,
reason: RevokeReason,
) -> (String, sea_query::Values) {
let mut update = Query::update();
update
.table(BackendSessions::Table)
.value(BackendSessions::RevokedReason, reason.as_str())
.and_where(Expr::col(BackendSessions::BackendUserId).eq(user_id))
.and_where(Expr::col(BackendSessions::RevokedReason).is_null());
if let Some(keep) = keep {
update.and_where(Expr::col(BackendSessions::TokenHash).ne(keep));
}
build(backend, update.to_owned())
}
pub(crate) async fn session_end(
db: &Db,
token_hash: &str,
now: DateTime<Utc>,
) -> Result<Option<SessionEnd>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([BackendSessions::RevokedReason, BackendSessions::ExpiresAt])
.from(BackendSessions::Table)
.and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
.to_owned(),
);
let Some(row) = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?
else {
return Ok(None);
};
if let Some(reason) = row
.get_text_opt("revoked_reason")?
.as_deref()
.and_then(RevokeReason::parse)
{
return Ok(Some(SessionEnd::Revoked(reason)));
}
let expires = parse_ts(&row.get_text("expires_at")?)?;
Ok((expires <= now).then_some(SessionEnd::Expired))
}
pub(crate) async fn change_password(
db: &Db,
user_id: i64,
password_hash: &str,
keep: Option<&str>,
actor_user_id: Option<i64>,
actor_username: &str,
) -> Result<u64, AuthError> {
let mut tx = db.pool.begin().await?;
let (sql, values) = build(
db.backend,
Query::select()
.column(BackendUsers::Username)
.from(BackendUsers::Table)
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
let Some(username) = bind_values(sqlx::query(&sql), values)
.fetch_optional(&mut *tx)
.await?
.map(|row| row.get_text("username"))
.transpose()?
else {
return Ok(0);
};
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendUsers::Table)
.value(BackendUsers::PasswordHash, password_hash)
.value(BackendUsers::PasswordChangedAt, now_ts())
.value(BackendUsers::MustChangePassword, false)
.value(BackendUsers::UpdatedAt, now_ts())
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
let done = bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?
.rows_affected();
if done == 0 {
return Ok(0);
}
let (sql, values) =
revoke_user_sessions_query(db.backend, user_id, keep, RevokeReason::PasswordChanged);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
let (sql, values) =
revoke_user_remember_tokens_query(db.backend, user_id, RevokeReason::PasswordChanged);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
let target = user_id.to_string();
let (sql, values) = insert_audit_log_query(
db.backend,
actor_user_id,
actor_username,
"backend.user.password_change",
Some("backend_user"),
Some(&target),
Some(&username),
None,
);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(done)
}
pub(crate) async fn delete_expired_sessions(db: &Db, now: DateTime<Utc>) -> Result<u64, AuthError> {
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(BackendSessions::Table)
.and_where(Expr::col(BackendSessions::ExpiresAt).lte(ts(now)))
.to_owned(),
);
let done = bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(done.rows_affected())
}
pub(crate) async fn delete_expired_remember_tokens(
db: &Db,
now: DateTime<Utc>,
) -> Result<u64, AuthError> {
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(BackendRememberTokens::Table)
.and_where(Expr::col(BackendRememberTokens::ExpiresAt).lte(ts(now)))
.to_owned(),
);
let done = bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(done.rows_affected())
}
pub(crate) async fn must_change_password(db: &Db, user_id: i64) -> Result<bool, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.column(BackendUsers::MustChangePassword)
.from(BackendUsers::Table)
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
match row {
Some(row) => Ok(row.get_bool("must_change_password")?),
None => Ok(false),
}
}
pub(crate) async fn set_must_change_password(
db: &Db,
user_id: i64,
must_change: bool,
) -> Result<u64, AuthError> {
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendUsers::Table)
.value(BackendUsers::MustChangePassword, must_change)
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
let done = bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(done.rows_affected())
}
pub(crate) async fn password_changed_at(
db: &Db,
user_id: i64,
) -> Result<Option<DateTime<Utc>>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.column(BackendUsers::PasswordChangedAt)
.from(BackendUsers::Table)
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
match row {
Some(row) => row
.get_text_opt("password_changed_at")?
.map(|s| parse_ts(&s))
.transpose(),
None => Ok(None),
}
}
pub(crate) async fn delete_session(db: &Db, token_hash: &str) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(BackendSessions::Table)
.and_where(Expr::col(BackendSessions::TokenHash).eq(token_hash))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub async fn insert_access_log(
db: &Db,
user_id: Option<i64>,
username_attempted: &str,
event: AccessEvent,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::insert()
.into_table(BackendAccessLog::Table)
.columns([
BackendAccessLog::BackendUserId,
BackendAccessLog::UsernameAttempted,
BackendAccessLog::Event,
BackendAccessLog::IpAddress,
BackendAccessLog::UserAgent,
BackendAccessLog::CreatedAt,
])
.values_panic([
user_id.into(),
normalize_key(username_attempted).into(),
event.as_str().into(),
ip_address.map(str::to_string).into(),
user_agent.map(str::to_string).into(),
now_ts().into(),
])
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AccessRecord {
pub event: String,
pub username_attempted: String,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub created_at: DateTime<Utc>,
}
pub async fn recent_access_log(
db: &Db,
user_id: i64,
limit: u64,
) -> Result<Vec<AccessRecord>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([
BackendAccessLog::Event,
BackendAccessLog::UsernameAttempted,
BackendAccessLog::IpAddress,
BackendAccessLog::UserAgent,
BackendAccessLog::CreatedAt,
])
.from(BackendAccessLog::Table)
.and_where(Expr::col(BackendAccessLog::BackendUserId).eq(user_id))
.order_by(BackendAccessLog::Id, Order::Desc)
.limit(limit)
.to_owned(),
);
let rows = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
rows.iter()
.map(|row| {
Ok(AccessRecord {
event: row.get_text("event")?,
username_attempted: row.get_text("username_attempted")?,
ip_address: row.get_text_opt("ip_address")?,
user_agent: row.get_text_opt("user_agent")?,
created_at: parse_ts(&row.get_text("created_at")?)?,
})
})
.collect()
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AuditRecord {
pub actor_user_id: Option<i64>,
pub actor_username: String,
pub action: String,
pub target_type: Option<String>,
pub target_id: Option<String>,
pub target_label: Option<String>,
pub detail: Option<String>,
pub created_at: DateTime<Utc>,
}
#[allow(clippy::too_many_arguments)]
pub async fn insert_audit_log(
db: &Db,
actor_user_id: Option<i64>,
actor_username: &str,
action: &str,
target_type: Option<&str>,
target_id: Option<&str>,
target_label: Option<&str>,
detail: Option<&str>,
) -> Result<(), AuthError> {
let (sql, values) = insert_audit_log_query(
db.backend,
actor_user_id,
actor_username,
action,
target_type,
target_id,
target_label,
detail,
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn insert_audit_log_query(
backend: laterite_core::DbBackend,
actor_user_id: Option<i64>,
actor_username: &str,
action: &str,
target_type: Option<&str>,
target_id: Option<&str>,
target_label: Option<&str>,
detail: Option<&str>,
) -> (String, sea_query::Values) {
build(
backend,
Query::insert()
.into_table(BackendAuditLog::Table)
.columns([
BackendAuditLog::ActorUserId,
BackendAuditLog::ActorUsername,
BackendAuditLog::Action,
BackendAuditLog::TargetType,
BackendAuditLog::TargetId,
BackendAuditLog::TargetLabel,
BackendAuditLog::Detail,
BackendAuditLog::CreatedAt,
])
.values_panic([
actor_user_id.into(),
actor_username.to_string().into(),
action.to_string().into(),
target_type.map(str::to_string).into(),
target_id.map(str::to_string).into(),
target_label.map(str::to_string).into(),
detail.map(str::to_string).into(),
now_ts().into(),
])
.to_owned(),
)
}
pub async fn recent_audit(db: &Db, limit: u64) -> Result<Vec<AuditRecord>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([
BackendAuditLog::ActorUserId,
BackendAuditLog::ActorUsername,
BackendAuditLog::Action,
BackendAuditLog::TargetType,
BackendAuditLog::TargetId,
BackendAuditLog::TargetLabel,
BackendAuditLog::Detail,
BackendAuditLog::CreatedAt,
])
.from(BackendAuditLog::Table)
.order_by(BackendAuditLog::Id, Order::Desc)
.limit(limit)
.to_owned(),
);
let rows = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
rows.iter().map(audit_from_row).collect()
}
fn audit_from_row(row: &AnyRow) -> Result<AuditRecord, AuthError> {
Ok(AuditRecord {
actor_user_id: row.get_int_opt("actor_user_id")?,
actor_username: row.get_text("actor_username")?,
action: row.get_text("action")?,
target_type: row.get_text_opt("target_type")?,
target_id: row.get_text_opt("target_id")?,
target_label: row.get_text_opt("target_label")?,
detail: row.get_text_opt("detail")?,
created_at: parse_ts(&row.get_text("created_at")?)?,
})
}
pub async fn count_recent_failures(
db: &Db,
username: &str,
since: DateTime<Utc>,
) -> Result<i64, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.expr(Expr::col(BackendAccessLog::Id).count())
.from(BackendAccessLog::Table)
.and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
.and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
.and_where(Expr::col(BackendAccessLog::CreatedAt).gte(ts(since)))
.to_owned(),
);
let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
.fetch_one(&db.pool)
.await?
.0;
Ok(count)
}
pub async fn count_recent_failures_from(
db: &Db,
username: &str,
address: Option<&str>,
since: DateTime<Utc>,
) -> Result<i64, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.expr(Expr::col(BackendAccessLog::Id).count())
.from(BackendAccessLog::Table)
.and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
.and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
.and_where(Expr::col(BackendAccessLog::CreatedAt).gte(ts(since)))
.and_where(address_is(address))
.to_owned(),
);
let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
.fetch_one(&db.pool)
.await?
.0;
Ok(count)
}
pub async fn count_recent_failures_by_address(
db: &Db,
address: Option<&str>,
since: DateTime<Utc>,
) -> Result<i64, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.expr(Expr::col(BackendAccessLog::Id).count())
.from(BackendAccessLog::Table)
.and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
.and_where(Expr::col(BackendAccessLog::CreatedAt).gte(ts(since)))
.and_where(address_is(address))
.to_owned(),
);
let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
.fetch_one(&db.pool)
.await?
.0;
Ok(count)
}
pub async fn any_address_locked(
db: &Db,
username: &str,
since: DateTime<Utc>,
max: i64,
) -> Result<bool, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.column(BackendAccessLog::IpAddress)
.from(BackendAccessLog::Table)
.and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
.and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
.and_where(Expr::col(BackendAccessLog::CreatedAt).gte(ts(since)))
.group_by_col(BackendAccessLog::IpAddress)
.and_having(Expr::expr(Expr::col(BackendAccessLog::Id).count()).gte(max))
.limit(1)
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
Ok(row.is_some())
}
fn address_is(address: Option<&str>) -> sea_query::SimpleExpr {
match address {
Some(ip) => Expr::col(BackendAccessLog::IpAddress).eq(ip),
None => Expr::col(BackendAccessLog::IpAddress).is_null(),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn create_user(
db: &Db,
username: &str,
email: &str,
first_name: &str,
last_name: Option<&str>,
password_hash: &str,
is_superuser: bool,
) -> Result<i64, AuthError> {
let now = now_ts();
let stmt = Query::insert()
.into_table(BackendUsers::Table)
.columns([
BackendUsers::Username,
BackendUsers::Email,
BackendUsers::FirstName,
BackendUsers::LastName,
BackendUsers::PasswordHash,
BackendUsers::IsSuperuser,
BackendUsers::PasswordChangedAt,
BackendUsers::CreatedAt,
BackendUsers::UpdatedAt,
])
.values_panic([
normalize_key(username).into(),
normalize_key(email).into(),
first_name.into(),
last_name.map(str::to_string).into(),
password_hash.into(),
is_superuser.into(),
now.clone().into(),
now.clone().into(),
now.into(),
])
.to_owned();
Ok(insert_returning_id(db, stmt, BackendUsers::Id).await?)
}
pub async fn any_user_exists(db: &Db) -> Result<bool, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.expr(Expr::col(BackendUsers::Id).count())
.from(BackendUsers::Table)
.to_owned(),
);
let count: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
.fetch_one(&db.pool)
.await?
.0;
Ok(count > 0)
}
pub async fn set_user_timezone(
db: &Db,
user_id: i64,
timezone: Option<&str>,
) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendUsers::Table)
.value(BackendUsers::Timezone, timezone.map(str::to_string))
.value(BackendUsers::UpdatedAt, now_ts())
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub async fn set_user_locale(db: &Db, user_id: i64, locale: Option<&str>) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendUsers::Table)
.value(BackendUsers::Locale, locale.map(str::to_string))
.value(BackendUsers::UpdatedAt, now_ts())
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub async fn create_role(
db: &Db,
code: &str,
name: &str,
permissions: &[String],
) -> Result<i64, AuthError> {
let perms = serde_json::to_string(permissions).unwrap_or_else(|_| "[]".to_string());
let stmt = Query::insert()
.into_table(BackendRoles::Table)
.columns([
BackendRoles::Code,
BackendRoles::Name,
BackendRoles::Permissions,
BackendRoles::CreatedAt,
])
.values_panic([code.into(), name.into(), perms.into(), now_ts().into()])
.to_owned();
Ok(insert_returning_id(db, stmt, BackendRoles::Id).await?)
}
pub struct SystemRole<'a> {
pub code: &'a str,
pub name: &'a str,
pub description: &'a str,
pub permissions: Vec<String>,
}
pub async fn sync_system_roles(db: &Db, roles: &[SystemRole<'_>]) -> Result<(), AuthError> {
for role in roles {
let perms = serde_json::to_string(&role.permissions).unwrap_or_else(|_| "[]".to_string());
let (sql, values) = build(
db.backend,
Query::insert()
.into_table(BackendRoles::Table)
.columns([
BackendRoles::Code,
BackendRoles::Name,
BackendRoles::Description,
BackendRoles::Permissions,
BackendRoles::IsSystem,
BackendRoles::CreatedAt,
])
.values_panic([
role.code.into(),
role.name.into(),
role.description.into(),
perms.clone().into(),
true.into(),
now_ts().into(),
])
.on_conflict(on_conflict_ignore([BackendRoles::Code]))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendRoles::Table)
.values([
(BackendRoles::Name, role.name.into()),
(BackendRoles::Description, role.description.into()),
(BackendRoles::Permissions, perms.into()),
(BackendRoles::IsSystem, true.into()),
])
.and_where(Expr::col(BackendRoles::Code).eq(role.code))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
}
Ok(())
}
pub async fn role_id_by_code(db: &Db, code: &str) -> Result<Option<i64>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.column(BackendRoles::Id)
.from(BackendRoles::Table)
.and_where(Expr::col(BackendRoles::Code).eq(code))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
Ok(match row {
Some(row) => Some(row.try_get::<i64, _>("id")?),
None => None,
})
}
pub async fn role_permissions(db: &Db, id: i64) -> Result<Vec<String>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.column(BackendRoles::Permissions)
.from(BackendRoles::Table)
.and_where(Expr::col(BackendRoles::Id).eq(id))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
Ok(match row {
Some(row) => serde_json::from_str(&row.get_text("permissions")?).unwrap_or_default(),
None => Vec::new(),
})
}
pub async fn role_is_system(db: &Db, id: i64) -> Result<bool, AuthError> {
let (sql, values) = is_system_query(db.backend, id);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
read_is_system(row)
}
pub async fn role_is_system_on(
conn: &mut sqlx::AnyConnection,
backend: laterite_core::DbBackend,
id: i64,
) -> Result<bool, AuthError> {
let (sql, values) = is_system_query(backend, id);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&mut *conn)
.await?;
read_is_system(row)
}
fn is_system_query(backend: laterite_core::DbBackend, id: i64) -> (String, sea_query::Values) {
build(
backend,
Query::select()
.column(BackendRoles::IsSystem)
.from(BackendRoles::Table)
.and_where(Expr::col(BackendRoles::Id).eq(id))
.to_owned(),
)
}
fn read_is_system(row: Option<AnyRow>) -> Result<bool, AuthError> {
Ok(match row {
Some(row) => row.get_bool("is_system")?,
None => false,
})
}
pub async fn rename_permissions(db: &Db, renames: &[(String, String)]) -> Result<usize, AuthError> {
if renames.is_empty() {
return Ok(0);
}
let mut changed = 0;
let (sql, values) = build(
db.backend,
Query::select()
.columns([BackendRoles::Id, BackendRoles::Permissions])
.from(BackendRoles::Table)
.to_owned(),
);
for row in bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?
{
let id = row.try_get::<i64, _>("id")?;
let held: Vec<String> =
serde_json::from_str(&row.get_text("permissions")?).unwrap_or_default();
let mut next: Vec<String> = held
.iter()
.map(|code| apply_rename(code, renames))
.collect();
next.dedup_by(|a, b| a == b);
let mut seen = std::collections::HashSet::new();
next.retain(|code| seen.insert(code.clone()));
if next != held {
let json = serde_json::to_string(&next).unwrap_or_else(|_| "[]".to_string());
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendRoles::Table)
.value(BackendRoles::Permissions, json)
.and_where(Expr::col(BackendRoles::Id).eq(id))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
changed += 1;
}
}
let (sql, values) = build(
db.backend,
Query::select()
.columns([BackendUsers::Id, BackendUsers::Permissions])
.from(BackendUsers::Table)
.to_owned(),
);
for row in bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?
{
let id = row.try_get::<i64, _>("id")?;
let held: HashMap<String, i64> =
serde_json::from_str(&row.get_text("permissions")?).unwrap_or_default();
let next: HashMap<String, i64> = held
.iter()
.map(|(code, state)| (apply_rename(code, renames), *state))
.collect();
if next != held {
let json = serde_json::to_string(&next).unwrap_or_else(|_| "{}".to_string());
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendUsers::Table)
.value(BackendUsers::Permissions, json)
.and_where(Expr::col(BackendUsers::Id).eq(id))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
changed += 1;
}
}
Ok(changed)
}
fn apply_rename(code: &str, renames: &[(String, String)]) -> String {
renames
.iter()
.find(|(old, _)| old == code)
.map(|(_, new)| new.clone())
.unwrap_or_else(|| code.to_string())
}
pub struct RoleSummary {
pub id: i64,
pub code: String,
pub name: String,
pub is_system: bool,
pub permissions: Vec<String>,
}
pub async fn list_roles(db: &Db) -> Result<Vec<RoleSummary>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([
BackendRoles::Id,
BackendRoles::Code,
BackendRoles::Name,
BackendRoles::IsSystem,
BackendRoles::Permissions,
])
.from(BackendRoles::Table)
.order_by(BackendRoles::Name, Order::Asc)
.to_owned(),
);
let rows = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(RoleSummary {
id: row.try_get::<i64, _>("id")?,
code: row.get_text("code")?,
name: row.get_text("name")?,
is_system: row.get_bool("is_system")?,
permissions: serde_json::from_str(&row.get_text("permissions")?)
.unwrap_or_default(),
})
})
.collect()
}
pub async fn user_role_ids(db: &Db, user_id: i64) -> Result<Vec<i64>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.column(BackendUserRoles::BackendRoleId)
.from(BackendUserRoles::Table)
.and_where(Expr::col(BackendUserRoles::BackendUserId).eq(user_id))
.to_owned(),
);
let rows = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
rows.into_iter()
.map(|row| Ok(row.try_get::<i64, _>("backend_role_id")?))
.collect()
}
pub async fn set_user_roles(db: &Db, user_id: i64, role_ids: &[i64]) -> Result<(), AuthError> {
let mut tx = db.pool.begin().await?;
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(BackendUserRoles::Table)
.and_where(Expr::col(BackendUserRoles::BackendUserId).eq(user_id))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
for role_id in role_ids {
let (sql, values) = build(
db.backend,
Query::insert()
.into_table(BackendUserRoles::Table)
.columns([
BackendUserRoles::BackendUserId,
BackendUserRoles::BackendRoleId,
])
.values_panic([user_id.into(), (*role_id).into()])
.on_conflict(on_conflict_ignore([
BackendUserRoles::BackendUserId,
BackendUserRoles::BackendRoleId,
]))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn assign_role(db: &Db, user_id: i64, role_id: i64) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::insert()
.into_table(BackendUserRoles::Table)
.columns([
BackendUserRoles::BackendUserId,
BackendUserRoles::BackendRoleId,
])
.values_panic([user_id.into(), role_id.into()])
.on_conflict(on_conflict_ignore([
BackendUserRoles::BackendUserId,
BackendUserRoles::BackendRoleId,
]))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
pub(crate) async fn set_user_active(db: &Db, user_id: i64, active: bool) -> Result<u64, AuthError> {
let mut tx = db.pool.begin().await?;
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendUsers::Table)
.value(BackendUsers::IsActive, active)
.and_where(Expr::col(BackendUsers::Id).eq(user_id))
.to_owned(),
);
let done = bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?
.rows_affected();
if done > 0 && !active {
let (sql, values) =
revoke_user_sessions_query(db.backend, user_id, None, RevokeReason::Deactivated);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
let (sql, values) =
revoke_user_remember_tokens_query(db.backend, user_id, RevokeReason::Deactivated);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(done)
}
pub(crate) async fn other_active_superusers(db: &Db, excluding: i64) -> Result<i64, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.expr(Expr::col(BackendUsers::Id).count())
.from(BackendUsers::Table)
.and_where(Expr::col(BackendUsers::IsSuperuser).eq(true))
.and_where(Expr::col(BackendUsers::IsActive).eq(true))
.and_where(Expr::col(BackendUsers::Id).ne(excluding))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_one(&db.pool)
.await?;
Ok(row.try_get::<i64, _>(0).unwrap_or(0))
}
pub async fn list_backend_users(db: &Db) -> Result<Vec<BackendUserSummary>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.columns([
BackendUsers::Id,
BackendUsers::Username,
BackendUsers::Email,
BackendUsers::FirstName,
BackendUsers::LastName,
BackendUsers::IsSuperuser,
BackendUsers::IsActive,
BackendUsers::CreatedAt,
])
.from(BackendUsers::Table)
.order_by(BackendUsers::CreatedAt, Order::Asc)
.to_owned(),
);
let rows = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
rows.iter().map(summary_from_row).collect()
}
pub async fn update_password_by_username(
db: &Db,
username: &str,
password_hash: &str,
) -> Result<u64, AuthError> {
let (sql, values) = build(
db.backend,
Query::update()
.table(BackendUsers::Table)
.value(BackendUsers::PasswordHash, password_hash)
.value(BackendUsers::PasswordChangedAt, now_ts())
.value(BackendUsers::UpdatedAt, now_ts())
.and_where(Expr::col(BackendUsers::Username).eq(normalize_key(username)))
.to_owned(),
);
let result = bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn clear_failed_attempts(db: &Db, username: &str) -> Result<u64, AuthError> {
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(BackendAccessLog::Table)
.and_where(Expr::col(BackendAccessLog::UsernameAttempted).eq(normalize_key(username)))
.and_where(Expr::col(BackendAccessLog::Event).eq(AccessEvent::LoginFailure.as_str()))
.to_owned(),
);
let result = bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(result.rows_affected())
}
pub async fn user_preference(
db: &Db,
user_id: i64,
key: &str,
) -> Result<Option<String>, AuthError> {
let (sql, values) = build(
db.backend,
Query::select()
.column(BackendUserPreferences::Value)
.from(BackendUserPreferences::Table)
.and_where(Expr::col(BackendUserPreferences::UserId).eq(user_id))
.and_where(Expr::col(BackendUserPreferences::PreferenceKey).eq(key))
.to_owned(),
);
let row = bind_values(sqlx::query(&sql), values)
.fetch_optional(&db.pool)
.await?;
Ok(row.and_then(|r| r.get_text_opt("value").ok().flatten()))
}
pub async fn set_user_preference(
db: &Db,
user_id: i64,
key: &str,
value: &str,
) -> Result<(), AuthError> {
let mut tx = db.pool.begin().await?;
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(BackendUserPreferences::Table)
.and_where(Expr::col(BackendUserPreferences::UserId).eq(user_id))
.and_where(Expr::col(BackendUserPreferences::PreferenceKey).eq(key))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
let (sql, values) = build(
db.backend,
Query::insert()
.into_table(BackendUserPreferences::Table)
.columns([
BackendUserPreferences::UserId,
BackendUserPreferences::PreferenceKey,
BackendUserPreferences::Value,
])
.values_panic([
user_id.into(),
key.to_string().into(),
value.to_string().into(),
])
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn clear_user_preference(db: &Db, user_id: i64, key: &str) -> Result<(), AuthError> {
let (sql, values) = build(
db.backend,
Query::delete()
.from_table(BackendUserPreferences::Table)
.and_where(Expr::col(BackendUserPreferences::UserId).eq(user_id))
.and_where(Expr::col(BackendUserPreferences::PreferenceKey).eq(key))
.to_owned(),
);
bind_values(sqlx::query(&sql), values)
.execute(&db.pool)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use laterite_core::testing::connect_test;
async fn operator(db: &Db) -> i64 {
let svc = crate::AuthService::new(db.clone(), crate::AuthConfig::default());
svc.create_superuser(crate::NewOperator {
username: "root",
email: "root@acme.test",
first_name: "Root",
last_name: None,
password: "rootpw12345",
timezone: None,
})
.await
.unwrap()
}
#[tokio::test]
async fn a_preference_round_trips_and_replaces() {
let (db, _guard) = connect_test(&[crate::migrations()]).await;
let id = operator(&db).await;
assert_eq!(
user_preference(&db, id, "list.columns./roles")
.await
.unwrap(),
None
);
set_user_preference(&db, id, "list.columns./roles", "code,name")
.await
.unwrap();
assert_eq!(
user_preference(&db, id, "list.columns./roles")
.await
.unwrap(),
Some("code,name".to_string())
);
set_user_preference(&db, id, "list.columns./roles", "code")
.await
.unwrap();
assert_eq!(
user_preference(&db, id, "list.columns./roles")
.await
.unwrap(),
Some("code".to_string())
);
clear_user_preference(&db, id, "list.columns./roles")
.await
.unwrap();
assert_eq!(
user_preference(&db, id, "list.columns./roles")
.await
.unwrap(),
None
);
}
#[tokio::test]
async fn preferences_are_scoped() {
let (db, _guard) = connect_test(&[crate::migrations()]).await;
let root = operator(&db).await;
let hash = crate::password::hash_password("otherpw12345").unwrap();
let other = create_user(&db, "other", "other@acme.test", "Other", None, &hash, false)
.await
.unwrap();
set_user_preference(&db, root, "list.columns./roles", "code")
.await
.unwrap();
assert_eq!(
user_preference(&db, other, "list.columns./roles")
.await
.unwrap(),
None
);
assert_eq!(
user_preference(&db, root, "list.columns./users")
.await
.unwrap(),
None
);
}
}