Skip to main content

alibi_sqlx/store/
users.rs

1use super::{SqlxStore, lock_exclusive, lock_shared};
2use crate::error::record_not_updated;
3use crate::model::{self, ActiveRow, SqlxModel};
4use crate::pool::{Engine, Exec, SqlxTransaction};
5use crate::schema::{AuthSchema, SqlxUserModel};
6use crate::sql::Sql;
7use crate::value::{ColumnKind, SqlValue};
8use alibi_core::error::{AuthError, AuthResult};
9use alibi_core::store::adapter::cancelled_by_hook;
10use alibi_core::store::{NumericTextInput, UserCreationDefaults, UserStore};
11use alibi_core::types::{CreateUser, ListUsersParams, UpdateUser};
12use alibi_core::user_validation::PreparedUserCreation;
13use alibi_core::{AuthUser, UserFilterValue};
14use async_trait::async_trait;
15use chrono::Utc;
16
17/// Row lock applied to a user lookup inside a transaction.
18#[derive(Clone, Copy)]
19pub(super) enum Lock {
20    None,
21    Shared,
22    Exclusive,
23}
24
25pub(super) async fn find_user_by_id<M: SqlxUserModel>(
26    exec: Exec<'_>,
27    id: &str,
28    lock: Lock,
29) -> AuthResult<Option<M>> {
30    let id = M::parse_id(id)?;
31    let mut sql = model::select_model::<M>(exec);
32    sql.push(" WHERE ");
33    sql.compare_model::<M>(M::TABLE, M::id_column(), " = ", id);
34    model::limit_one(&mut sql);
35    match lock {
36        Lock::None => {}
37        Lock::Shared => lock_shared(&mut sql),
38        Lock::Exclusive => lock_exclusive(&mut sql),
39    }
40    exec.fetch_optional(sql).await
41}
42
43pub(super) async fn provider_verification_output<M: SqlxUserModel>(
44    exec: Exec<'_>,
45    id: &str,
46) -> AuthResult<Option<serde_json::Value>> {
47    if exec.engine() != Engine::Sqlite {
48        return Ok(None);
49    }
50    let Some(column) = M::PROVIDER_VERIFICATION_COLUMN else {
51        return Ok(None);
52    };
53    let mut sql = Sql::new(exec.engine());
54    sql.push("SELECT CASE typeof(");
55    sql.ident(column);
56    sql.push(") WHEN 'text' THEN json_quote(");
57    sql.ident(column);
58    sql.push(") WHEN 'null' THEN 'null' ELSE CASE WHEN ");
59    sql.ident(column);
60    sql.push(" = 1 THEN 'true' ELSE 'false' END END FROM ");
61    sql.ident(M::TABLE);
62    sql.push(" WHERE ");
63    sql.compare(M::TABLE, M::id_column(), " = ", M::parse_id(id)?);
64    sql.push(" LIMIT 1");
65    exec.fetch_scalar::<String>(sql)
66        .await?
67        .map(|value| {
68            alibi_core::utils::json::from_slice(value.as_bytes())
69                .map_err(|error| AuthError::internal(error.to_string()))
70        })
71        .transpose()
72}
73
74fn stage_provider_verification<M: SqlxUserModel>(
75    active: &mut ActiveRow,
76    value: Option<serde_json::Value>,
77    engine: Engine,
78) -> AuthResult<()> {
79    let Some(value) = value else {
80        return Ok(());
81    };
82    // Boolean inputs retain the ordinary typed-model path on every engine.
83    if value.is_boolean() {
84        return Ok(());
85    }
86    let column = M::PROVIDER_VERIFICATION_COLUMN.ok_or_else(|| {
87        AuthError::internal("The user model does not expose its provider verification column")
88    })?;
89    let value = match value {
90        serde_json::Value::Null => SqlValue::Bool(None),
91        serde_json::Value::String(value) => SqlValue::Text(Some(value)),
92        serde_json::Value::Number(value) => {
93            if engine == Engine::Postgres {
94                SqlValue::Text(Some(
95                    alibi_core::utils::json::number_to_string(&value)
96                        .map_err(|error| AuthError::internal(error.to_string()))?,
97                ))
98            } else if let Some(value) = value.as_i64() {
99                SqlValue::BigInt(Some(value))
100            } else {
101                SqlValue::Double(value.as_f64())
102            }
103        }
104        _ => {
105            return Err(AuthError::internal(
106                "Unsupported provider verification SQL parameter",
107            ));
108        }
109    };
110    active.set(column, value);
111    Ok(())
112}
113
114async fn stage_provider_text<M: SqlxUserModel>(
115    exec: Exec<'_>,
116    active: &mut ActiveRow,
117    name: Option<serde_json::Value>,
118    image: Option<serde_json::Value>,
119) -> AuthResult<()> {
120    for (field, raw) in [("name", name), ("image", image)] {
121        let Some(raw) = raw else {
122            continue;
123        };
124        if raw.is_array() || raw.is_object() {
125            return Err(AuthError::internal(
126                "Unsupported provider text SQL parameter",
127            ));
128        }
129        let column = if field == "name" {
130            Some(M::name_column())
131        } else {
132            M::list_users_column("image")
133        }
134        .ok_or_else(|| {
135            AuthError::internal("The user model does not expose its provider text column")
136        })?;
137        let value =
138            crate::additional_fields::raw_value(&alibi_core::utils::json::JsValue::from(raw))?;
139        let value =
140            crate::additional_fields::prepare_value(exec, M::column_kind(column), value).await?;
141        active.set(column, value);
142    }
143    Ok(())
144}
145
146impl<S> SqlxStore<S>
147where
148    S: AuthSchema,
149    S::User: SqlxUserModel,
150{
151    async fn create_user_with_connection(
152        &self,
153        exec: Exec<'_>,
154        tx: Option<&SqlxTransaction>,
155        mut create_user: CreateUser,
156        defaults: UserCreationDefaults,
157    ) -> AuthResult<S::User> {
158        let hook_context = self.hook_context(tx);
159        for hook in self.hooks() {
160            if hook
161                .before_create_user(&mut create_user, &hook_context)
162                .await?
163                .is_cancelled()
164            {
165                return Err(AuthError::UserCreationCancelled);
166            }
167        }
168        let mut create_user = defaults.apply(create_user)?;
169        if let Some(username) = create_user.username.as_mut() {
170            *username = username.to_lowercase();
171        }
172        let now = Utc::now();
173        if create_user.id.is_none() {
174            create_user.id = self
175                .generated_id(
176                    exec,
177                    "user",
178                    <S::User as SqlxModel>::TABLE,
179                    S::User::id_column(),
180                )
181                .await?;
182        }
183        let user_id = create_user
184            .id
185            .as_deref()
186            .map(S::User::parse_id)
187            .transpose()?;
188        let mut fields = std::mem::take(&mut create_user.additional_fields);
189        fields.apply_adapter_transforms_async().await?;
190        let provider_name = create_user.provider_name.take();
191        let provider_image = create_user.provider_image.take();
192        let provider_verification = create_user.provider_email_verified.take();
193        let mut active = S::User::new_active(user_id, create_user, now);
194        stage_provider_verification::<S::User>(&mut active, provider_verification, exec.engine())?;
195        stage_provider_text::<S::User>(exec, &mut active, provider_name, provider_image).await?;
196        let backend = exec.engine();
197        for (column, value) in S::User::additional_field_bindings(&fields, backend)? {
198            let value = crate::additional_fields::prepare_value(
199                exec,
200                <S::User as SqlxModel>::column_kind(column),
201                value,
202            )
203            .await?;
204            S::User::set_additional_field(&mut active, column, value, backend)?;
205        }
206        S::User::prepare_json_metadata(&mut active, backend)?;
207
208        let user = model::insert::<S::User>(exec, &active).await?;
209        if tx.is_none() {
210            for hook in self.hooks() {
211                hook.after_create_user(&user, &hook_context).await?;
212            }
213        }
214        Ok(user)
215    }
216
217    pub(crate) async fn create_user_in_tx(
218        &self,
219        tx: &SqlxTransaction,
220        mut create_user: CreateUser,
221    ) -> AuthResult<S::User> {
222        create_user.email = create_user.email.map(|email| normalize_user_email(&email));
223        self.create_user_with_connection(
224            Exec::tx(tx),
225            Some(tx),
226            create_user,
227            alibi_core::store::UserCreationDefaults::default(),
228        )
229        .await
230    }
231
232    pub(crate) async fn create_user_prepared_in_tx(
233        &self,
234        tx: &SqlxTransaction,
235        prepared: PreparedUserCreation,
236    ) -> AuthResult<S::User> {
237        let (data, defaults) = prepared.into_parts();
238        self.create_user_with_connection(Exec::tx(tx), Some(tx), data, defaults)
239            .await
240    }
241
242    async fn users_by_ids(&self, ids: &[String], limit: Option<f64>) -> AuthResult<Vec<S::User>> {
243        if ids.is_empty() {
244            return Ok(Vec::new());
245        }
246        let user_ids = ids
247            .iter()
248            .map(|id| S::User::parse_id(id))
249            .collect::<AuthResult<Vec<_>>>()?;
250        let mut sql = model::select_model::<S::User>(self.exec());
251        sql.push(" WHERE ");
252        sql.column(<S::User as SqlxModel>::TABLE, S::User::id_column());
253        sql.push(" IN ");
254        sql.bind_list(user_ids);
255        super::bind_page(&mut sql, limit, None);
256        self.exec().fetch_all(sql).await
257    }
258
259    /// Bind an admin user-list filter the shared projection cannot evaluate.
260    fn push_user_filter(
261        &self,
262        sql: &mut Sql,
263        filter_field: Option<&str>,
264        operator: &str,
265        value: &UserFilterValue,
266    ) -> AuthResult<()> {
267        let table = <S::User as SqlxModel>::TABLE;
268        let field = filter_field
269            .filter(|field| !field.is_empty())
270            .unwrap_or("email");
271        let column = S::User::list_users_column(field)
272            .ok_or_else(|| AuthError::bad_request("User filter field has no configured column"))?;
273        let operands = match value {
274            UserFilterValue::Multiple(values) => values.as_slice(),
275            UserFilterValue::Scalar(value) if operator != "in" => std::slice::from_ref(value),
276            UserFilterValue::Scalar(_) => {
277                return Err(AuthError::bad_request("Value must be an array"));
278            }
279        };
280        // Upstream coerces a scalar string on a boolean field before binding;
281        // array operands keep their original strings.
282        let numeric_cast = if self.exec().engine() == Engine::Postgres {
283            match <S::User as SqlxModel>::column_kind(column) {
284                ColumnKind::Int => Some("int4"),
285                ColumnKind::BigInt => Some("int8"),
286                ColumnKind::Float => Some("float4"),
287                ColumnKind::Double => Some("float8"),
288                _ => None,
289            }
290        } else {
291            None
292        };
293        let bindings: Vec<SqlValue> = if numeric_cast.is_some() {
294            numeric_filter_text(operands)
295                .into_iter()
296                .map(Into::into)
297                .collect()
298        } else if matches!(value, UserFilterValue::Scalar(_))
299            && <S::User as SqlxModel>::column_kind(column) == ColumnKind::Boolean
300        {
301            operands
302                .iter()
303                .map(|value_2| (value_2 == "true").into())
304                .collect()
305        } else {
306            operands
307                .iter()
308                .cloned()
309                .map(|value| S::User::column_value(column, value.into()))
310                .collect()
311        };
312        sql.push(" WHERE ");
313        match operator {
314            "in" | "not_in" if bindings.is_empty() && numeric_cast.is_none() => {
315                sql.push(if operator == "in" { "1 = 2" } else { "1 = 1" });
316            }
317            "in" => {
318                sql.column(table, column);
319                sql.push(" IN ");
320                bind_filter_values(sql, bindings, numeric_cast);
321            }
322            "not_in" => {
323                sql.column(table, column);
324                sql.push(" NOT IN ");
325                bind_filter_values(sql, bindings, numeric_cast);
326            }
327            // The pinned adapter interpolates the complete array's
328            // comma-joined value into a bound LIKE pattern. Actual SQL
329            // retains backend case, wildcard and NULL semantics.
330            "contains" => {
331                sql.compare_model::<S::User>(
332                    table,
333                    column,
334                    " LIKE ",
335                    format!("%{}%", operands.join(",")),
336                );
337            }
338            "starts_with" => {
339                sql.compare_model::<S::User>(
340                    table,
341                    column,
342                    " LIKE ",
343                    format!("{}%", operands.join(",")),
344                );
345            }
346            "ends_with" => {
347                sql.compare_model::<S::User>(
348                    table,
349                    column,
350                    " LIKE ",
351                    format!("%{}", operands.join(",")),
352                );
353            }
354            "eq" | "ne" | "lt" | "lte" | "gt" | "gte" => {
355                let comparison = match operator {
356                    "eq" => " = ",
357                    "ne" => " <> ",
358                    "lt" => " < ",
359                    "lte" => " <= ",
360                    "gt" => " > ",
361                    _ => " >= ",
362                };
363                sql.ident(column);
364                sql.push(comparison);
365                bind_filter_values(sql, bindings, numeric_cast);
366            }
367            _ => return Err(AuthError::bad_request("Unsupported user filter operator")),
368        }
369        Ok(())
370    }
371
372    fn user_lookup(&self, column: &str, value: impl Into<SqlValue>) -> Sql {
373        let mut sql = model::select_model::<S::User>(self.exec());
374        sql.push(" WHERE ");
375        sql.compare_model::<S::User>(<S::User as SqlxModel>::TABLE, column, " = ", value);
376        sql.push(" LIMIT 1");
377        sql
378    }
379}
380
381#[async_trait]
382impl<S> UserStore<S> for SqlxStore<S>
383where
384    S: AuthSchema + Send + Sync,
385    S::User: SqlxUserModel,
386{
387    async fn provider_verification_output(
388        &self,
389        id: &str,
390    ) -> AuthResult<Option<serde_json::Value>> {
391        provider_verification_output::<S::User>(self.exec(), id).await
392    }
393
394    async fn create_user(&self, mut create_user: CreateUser) -> AuthResult<S::User> {
395        create_user.email = create_user.email.map(|email| normalize_user_email(&email));
396        self.create_user_with_connection(
397            self.exec(),
398            None,
399            create_user,
400            alibi_core::store::UserCreationDefaults::default(),
401        )
402        .await
403    }
404
405    async fn create_user_prepared(&self, prepared: PreparedUserCreation) -> AuthResult<S::User> {
406        let (data, defaults) = prepared.into_parts();
407        self.create_user_with_connection(self.exec(), None, data, defaults)
408            .await
409    }
410
411    async fn coerce_user_text_number(&self, input: NumericTextInput) -> AuthResult<String> {
412        let value: SqlValue = match input {
413            NumericTextInput::Integer(value) => value.into(),
414            NumericTextInput::Real(value) if !value.is_nan() => value.into(),
415            NumericTextInput::Real(_) => {
416                return Err(AuthError::bad_request("Numeric text input must not be NaN"));
417            }
418        };
419        let backend = self.exec().engine();
420        let mut sql = Sql::with(backend, "SELECT CAST(");
421        sql.bind(value);
422        sql.push(" AS TEXT) AS value");
423        // The database's own CAST decides the stored text.
424        self.exec()
425            .fetch_scalar::<String>(sql)
426            .await?
427            .ok_or_else(|| AuthError::internal("Numeric text coercion returned no value"))
428    }
429
430    async fn get_user_by_id(&self, id: &str) -> AuthResult<Option<S::User>> {
431        find_user_by_id::<S::User>(self.exec(), id, Lock::None).await
432    }
433
434    async fn list_users_by_ids(&self, ids: &[String]) -> AuthResult<Vec<S::User>> {
435        self.users_by_ids(ids, None).await
436    }
437
438    async fn list_users_by_ids_page(&self, ids: &[String], limit: f64) -> AuthResult<Vec<S::User>> {
439        self.users_by_ids(ids, Some(limit)).await
440    }
441
442    async fn get_user_by_email(&self, email: &str) -> AuthResult<Option<S::User>> {
443        let email = normalize_user_email(email);
444        let sql = self.user_lookup(S::User::email_column(), email);
445        self.exec().fetch_optional(sql).await
446    }
447
448    async fn get_user_by_username(&self, username: &str) -> AuthResult<Option<S::User>> {
449        let Some(column) = S::User::username_column() else {
450            return Ok(None);
451        };
452        let sql = self.user_lookup(column, username);
453        self.exec().fetch_optional(sql).await
454    }
455
456    async fn get_user_by_phone_number(&self, phone_number: &str) -> AuthResult<Option<S::User>> {
457        let column = S::User::phone_number_column()
458            .ok_or_else(|| AuthError::internal("the user schema has no phone-number field"))?;
459        let sql = self.user_lookup(column, phone_number);
460        self.exec().fetch_optional(sql).await
461    }
462
463    async fn update_user(&self, id: &str, mut update: UpdateUser) -> AuthResult<S::User> {
464        update.email = update.email.map(|email| normalize_user_email(&email));
465        _ = S::User::parse_id(id)?;
466        let hook_context = self.hook_context(None);
467        for hook in self.hooks() {
468            if hook
469                .before_update_user(id, &mut update, &hook_context)
470                .await
471                .map_err(alibi_core::store::adapter::callback_error)?
472                .is_cancelled()
473            {
474                return Err(cancelled_by_hook("user update"));
475            }
476        }
477        if let Some(username) = update.username.as_mut() {
478            *username = username.to_lowercase();
479        }
480        let Some(model) = find_user_by_id::<S::User>(self.exec(), id, Lock::None).await? else {
481            return Err(AuthError::UserNotFound);
482        };
483
484        let mut active = model.into_active();
485        let mut fields = std::mem::take(&mut update.additional_fields);
486        fields.apply_adapter_transforms_async().await?;
487        let provider_name = update.provider_name.take();
488        let provider_image = update.provider_image.take();
489        let provider_verification = update.provider_email_verified.take();
490        S::User::apply_update(&mut active, update, Utc::now());
491        stage_provider_verification::<S::User>(
492            &mut active,
493            provider_verification,
494            self.exec().engine(),
495        )?;
496        stage_provider_text::<S::User>(self.exec(), &mut active, provider_name, provider_image)
497            .await?;
498        let backend = self.exec().engine();
499        for (column, value) in S::User::additional_field_bindings(&fields, backend)? {
500            let value = crate::additional_fields::prepare_value(
501                self.exec(),
502                <S::User as SqlxModel>::column_kind(column),
503                value,
504            )
505            .await?;
506            S::User::set_additional_field(&mut active, column, value, backend)?;
507        }
508        S::User::prepare_json_metadata(&mut active, backend)?;
509
510        let user = model::update::<S::User>(self.exec(), &active)
511            .await?
512            .ok_or_else(record_not_updated)?;
513        for hook in self.hooks() {
514            hook.after_update_user(&user, &hook_context)
515                .await
516                .map_err(alibi_core::store::adapter::callback_error)?;
517        }
518        Ok(user)
519    }
520
521    async fn delete_user(&self, id: &str) -> AuthResult<()> {
522        _ = S::User::parse_id(id)?;
523        let Some(user) = self.get_user_by_id(id).await? else {
524            return Err(AuthError::UserNotFound);
525        };
526        let hook_context = self.hook_context(None);
527        for hook in self.hooks() {
528            if hook
529                .before_delete_user(&user, &hook_context)
530                .await?
531                .is_cancelled()
532            {
533                return Ok(());
534            }
535        }
536        // API keys retain their polymorphic reference. Redemption resolves the
537        // owner and rejects a missing user with the public identity error.
538        let owner = user.id().into_owned();
539        let id = id.to_owned();
540        self.in_transaction(true, async move |tx| {
541            let exec = Exec::tx(tx);
542            _ = find_user_by_id::<S::User>(exec, &id, Lock::Exclusive).await?;
543            super::teams::remove_owned_team_members(tx, &owner, None).await?;
544            super::wallets::remove_owned_wallets(tx, &owner).await?;
545            let mut users = Sql::with(exec.engine(), "DELETE FROM ");
546            users.ident(<S::User as SqlxModel>::TABLE);
547            users.push(" WHERE ");
548            users.compare_model::<S::User>(
549                <S::User as SqlxModel>::TABLE,
550                S::User::id_column(),
551                " = ",
552                S::User::parse_id(&id)?,
553            );
554            _ = exec.execute(users).await?;
555            Ok(())
556        })
557        .await?;
558        for hook in self.hooks() {
559            hook.after_delete_user(&user, &hook_context).await?;
560        }
561        Ok(())
562    }
563
564    async fn list_users(&self, mut params: ListUsersParams) -> AuthResult<(Vec<S::User>, usize)> {
565        let table = <S::User as SqlxModel>::TABLE;
566        let mut sql = model::select_model::<S::User>(self.exec());
567        if let Some(value) = &params.filter_value {
568            let operator = params.filter_operator.as_deref().unwrap_or("eq");
569            if matches!(value, UserFilterValue::Multiple(_))
570                || matches!(operator, "in" | "not_in")
571                || !is_projected_field(params.filter_field.as_deref().unwrap_or("email"))
572            {
573                self.push_user_filter(&mut sql, params.filter_field.as_deref(), operator, value)?;
574                // Only this already executed filter is removed from the common
575                // paging/search helper; other fields and total remain intact.
576                params.filter_value = None;
577            }
578        }
579        // Numeric IDs and application fields retain physical column ordering.
580        // The shared projection cannot know the application's column types.
581        let physical_sort = params
582            .sort_by
583            .as_deref()
584            .filter(|field| !is_projected_field(field));
585        let presorted = if let Some(field) = physical_sort {
586            let column = S::User::list_users_column(field).ok_or_else(|| {
587                AuthError::bad_request("User sort field has no configured column")
588            })?;
589            sql.push(" ORDER BY ");
590            sql.column(table, column);
591            sql.push(if params.sort_direction.as_deref() == Some("desc") {
592                " DESC"
593            } else {
594                " ASC"
595            });
596            true
597        } else {
598            false
599        };
600        let models = self.exec().fetch_all(sql).await?;
601
602        Ok(if presorted {
603            alibi_core::user_query::apply_list_users_presorted(models, &params)
604        } else {
605            alibi_core::user_query::apply_list_users(models, &params)
606        })
607    }
608}
609
610/// Fields the shared list-users projection filters and sorts itself.
611fn is_projected_field(field: &str) -> bool {
612    matches!(
613        field,
614        "email"
615            | "name"
616            | "username"
617            | "role"
618            | "banned"
619            | "createdAt"
620            | "updatedAt"
621            | "banExpires"
622    )
623}
624
625fn normalize_user_email(email: &str) -> String {
626    email.to_lowercase()
627}
628
629// Source converts a nonempty numeric scalar, or every numeric array operand,
630// to Number before node-postgres serializes the parameter. An invalid array
631// keeps all its original strings. PostgreSQL validates the resulting text.
632fn numeric_filter_text(values: &[String]) -> Vec<String> {
633    let numbers = values
634        .iter()
635        .map(|value| {
636            (!alibi_core::utils::javascript::trim(value).is_empty())
637                .then(|| alibi_core::utils::javascript::string_to_number(value))
638                .flatten()
639                .filter(|number| !number.is_nan())
640        })
641        .collect::<Option<Vec<_>>>();
642    numbers.map_or_else(
643        || values.to_vec(),
644        |numbers| {
645            numbers
646                .into_iter()
647                .map(|number| ryu_js::Buffer::new().format(number).to_owned())
648                .collect()
649        },
650    )
651}
652
653fn bind_filter_values(sql: &mut Sql, values: Vec<SqlValue>, cast: Option<&str>) {
654    sql.push("(");
655    for (index, value) in values.into_iter().enumerate() {
656        if index > 0 {
657            sql.push(", ");
658        }
659        sql.bind(value);
660        if let Some(cast) = cast {
661            sql.push("::");
662            sql.push(cast);
663        }
664    }
665    sql.push(")");
666}