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