Skip to main content

mas_storage_pg/oauth2/
session.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use std::net::IpAddr;
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use mas_data_model::{BrowserSession, Client, Clock, Session, SessionState, UlidExt as _, User};
13use mas_storage::{
14    Page, Pagination,
15    oauth2::{OAuth2SessionFilter, OAuth2SessionRepository},
16    pagination::Node,
17};
18use oauth2_types::scope::{Scope, ScopeToken};
19use rand::Rng;
20use sea_query::{
21    Condition, Expr, ExprTrait, PgFunc, PostgresQueryBuilder, Query, SimpleExpr, enum_def,
22    extension::postgres::PgExpr,
23};
24use sea_query_sqlx::SqlxBinder;
25use sqlx::PgConnection;
26use ulid::Ulid;
27use uuid::Uuid;
28
29use crate::{
30    DatabaseError, DatabaseInconsistencyError,
31    filter::{Filter, StatementExt},
32    iden::{OAuth2Clients, OAuth2Sessions, UserSessions},
33    pagination::QueryBuilderExt,
34    tracing::ExecuteExt,
35    ulid_at::{max_ulid_at, min_ulid_at},
36};
37
38/// An implementation of [`OAuth2SessionRepository`] for a PostgreSQL connection
39pub struct PgOAuth2SessionRepository<'c> {
40    conn: &'c mut PgConnection,
41}
42
43impl<'c> PgOAuth2SessionRepository<'c> {
44    /// Create a new [`PgOAuth2SessionRepository`] from an active PostgreSQL
45    /// connection
46    pub fn new(conn: &'c mut PgConnection) -> Self {
47        Self { conn }
48    }
49}
50
51#[derive(sqlx::FromRow)]
52#[enum_def]
53struct OAuthSessionLookup {
54    oauth2_session_id: Uuid,
55    user_id: Option<Uuid>,
56    user_session_id: Option<Uuid>,
57    oauth2_client_id: Uuid,
58    scope_list: Vec<String>,
59    created_at: DateTime<Utc>,
60    finished_at: Option<DateTime<Utc>>,
61    user_agent: Option<String>,
62    last_active_at: Option<DateTime<Utc>>,
63    last_active_ip: Option<IpAddr>,
64    human_name: Option<String>,
65}
66
67impl Node<Ulid> for OAuthSessionLookup {
68    fn cursor(&self) -> Ulid {
69        self.oauth2_session_id.into()
70    }
71}
72
73impl TryFrom<OAuthSessionLookup> for Session {
74    type Error = DatabaseInconsistencyError;
75
76    fn try_from(value: OAuthSessionLookup) -> Result<Self, Self::Error> {
77        let id = Ulid::from(value.oauth2_session_id);
78        let scope: Result<Scope, _> = value
79            .scope_list
80            .iter()
81            .map(|s| s.parse::<ScopeToken>())
82            .collect();
83        let scope = scope.map_err(|e| {
84            DatabaseInconsistencyError::on("oauth2_sessions")
85                .column("scope")
86                .row(id)
87                .source(e)
88        })?;
89
90        let state = match value.finished_at {
91            None => SessionState::Valid,
92            Some(finished_at) => SessionState::Finished { finished_at },
93        };
94
95        Ok(Session {
96            id,
97            state,
98            created_at: value.created_at,
99            client_id: value.oauth2_client_id.into(),
100            user_id: value.user_id.map(Ulid::from),
101            user_session_id: value.user_session_id.map(Ulid::from),
102            scope,
103            user_agent: value.user_agent,
104            last_active_at: value.last_active_at,
105            last_active_ip: value.last_active_ip,
106            human_name: value.human_name,
107        })
108    }
109}
110
111impl Filter for OAuth2SessionFilter<'_> {
112    fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition {
113        sea_query::Condition::all()
114            .add_option(self.user().map(|user| {
115                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserId)).eq(Uuid::from(user.id))
116            }))
117            .add_option(self.client().map(|client| {
118                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2ClientId))
119                    .eq(Uuid::from(client.id))
120            }))
121            .add_option(self.clients().map(|clients| {
122                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2ClientId))
123                    .is_in(clients.iter().map(|c| Uuid::from(c.id)))
124            }))
125            .add_option(self.client_kind().map(|client_kind| {
126                // This builds either a:
127                // `WHERE oauth2_client_id = ANY(...)`
128                // or a `WHERE oauth2_client_id <> ALL(...)`
129                let static_clients = Query::select()
130                    .expr(Expr::col((
131                        OAuth2Clients::Table,
132                        OAuth2Clients::OAuth2ClientId,
133                    )))
134                    .and_where(Expr::col((OAuth2Clients::Table, OAuth2Clients::IsStatic)))
135                    .from(OAuth2Clients::Table)
136                    .take();
137                if client_kind.is_static() {
138                    Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2ClientId))
139                        .eq(Expr::any(static_clients))
140                } else {
141                    Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2ClientId))
142                        .ne(Expr::all(static_clients))
143                }
144            }))
145            .add_option(self.device().map(|device| -> SimpleExpr {
146                if let Ok([stable_scope_token, unstable_scope_token]) = device.to_scope_token() {
147                    Condition::any()
148                        .add(
149                            Expr::val(stable_scope_token.to_string()).eq(PgFunc::any(Expr::col((
150                                OAuth2Sessions::Table,
151                                OAuth2Sessions::ScopeList,
152                            )))),
153                        )
154                        .add(Expr::val(unstable_scope_token.to_string()).eq(PgFunc::any(
155                            Expr::col((OAuth2Sessions::Table, OAuth2Sessions::ScopeList)),
156                        )))
157                        .into()
158                } else {
159                    // If the device ID can't be encoded as a scope token, match no rows
160                    Expr::val(false)
161                }
162            }))
163            .add_option(self.browser_session().map(|browser_session| {
164                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserSessionId))
165                    .eq(Uuid::from(browser_session.id))
166            }))
167            .add_option(self.browser_session_filter().map(|browser_session_filter| {
168                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserSessionId)).in_subquery(
169                    Query::select()
170                        .expr(Expr::col((
171                            UserSessions::Table,
172                            UserSessions::UserSessionId,
173                        )))
174                        .apply_filter(browser_session_filter)
175                        .from(UserSessions::Table)
176                        .take(),
177                )
178            }))
179            .add_option(self.state().map(|state| {
180                if state.is_active() {
181                    Expr::col((OAuth2Sessions::Table, OAuth2Sessions::FinishedAt)).is_null()
182                } else {
183                    Expr::col((OAuth2Sessions::Table, OAuth2Sessions::FinishedAt)).is_not_null()
184                }
185            }))
186            .add_option(self.scope().map(|scope| {
187                let scope: Vec<String> = scope.iter().map(|s| s.as_str().to_owned()).collect();
188                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::ScopeList)).contains(scope)
189            }))
190            .add_option(self.any_user().map(|any_user| {
191                if any_user {
192                    Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserId)).is_not_null()
193                } else {
194                    Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserId)).is_null()
195                }
196            }))
197            .add_option(self.last_active_after().map(|last_active_after| {
198                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::LastActiveAt))
199                    .gt(last_active_after)
200            }))
201            .add_option(self.last_active_before().map(|last_active_before| {
202                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::LastActiveAt))
203                    .lt(last_active_before)
204            }))
205            .add_option(self.created_after().map(|created_after| {
206                // ULIDs encode the creation time in their high 48 bits, so we
207                // can use the primary key index to filter on creation time
208                // without touching the `created_at` column.
209                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2SessionId))
210                    .gt(max_ulid_at(created_after))
211            }))
212            .add_option(self.created_before().map(|created_before| {
213                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2SessionId))
214                    .lt(min_ulid_at(created_before))
215            }))
216    }
217}
218
219#[async_trait]
220impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> {
221    type Error = DatabaseError;
222
223    #[tracing::instrument(
224        name = "db.oauth2_session.lookup",
225        skip_all,
226        fields(
227            db.query.text,
228            session.id = %id,
229        ),
230    )]
231    async fn lookup(&mut self, id: Ulid) -> Result<Option<Session>, Self::Error> {
232        let res = sqlx::query_as!(
233            OAuthSessionLookup,
234            r#"
235                SELECT oauth2_session_id
236                     , user_id
237                     , user_session_id
238                     , oauth2_client_id
239                     , scope_list
240                     , created_at
241                     , finished_at
242                     , user_agent
243                     , last_active_at
244                     , last_active_ip as "last_active_ip: IpAddr"
245                     , human_name
246                FROM oauth2_sessions
247
248                WHERE oauth2_session_id = $1
249            "#,
250            Uuid::from(id),
251        )
252        .traced()
253        .fetch_optional(&mut *self.conn)
254        .await?;
255
256        let Some(session) = res else { return Ok(None) };
257
258        Ok(Some(session.try_into()?))
259    }
260
261    #[tracing::instrument(
262        name = "db.oauth2_session.add",
263        skip_all,
264        fields(
265            db.query.text,
266            %client.id,
267            session.id,
268            session.scope = %scope,
269        ),
270    )]
271    async fn add(
272        &mut self,
273        rng: &mut (dyn Rng + Send),
274        clock: &dyn Clock,
275        client: &Client,
276        user: Option<&User>,
277        user_session: Option<&BrowserSession>,
278        scope: Scope,
279    ) -> Result<Session, Self::Error> {
280        let created_at = clock.now();
281        let id = Ulid::from_datetime_with_rng(created_at, rng);
282        tracing::Span::current().record("session.id", tracing::field::display(id));
283
284        let scope_list: Vec<String> = scope.iter().map(|s| s.as_str().to_owned()).collect();
285
286        sqlx::query!(
287            r#"
288                INSERT INTO oauth2_sessions
289                    ( oauth2_session_id
290                    , user_id
291                    , user_session_id
292                    , oauth2_client_id
293                    , scope_list
294                    , created_at
295                    )
296                VALUES ($1, $2, $3, $4, $5, $6)
297            "#,
298            Uuid::from(id),
299            user.map(|u| Uuid::from(u.id)),
300            user_session.map(|s| Uuid::from(s.id)),
301            Uuid::from(client.id),
302            &scope_list,
303            created_at,
304        )
305        .traced()
306        .execute(&mut *self.conn)
307        .await?;
308
309        Ok(Session {
310            id,
311            state: SessionState::Valid,
312            created_at,
313            user_id: user.map(|u| u.id),
314            user_session_id: user_session.map(|s| s.id),
315            client_id: client.id,
316            scope,
317            user_agent: None,
318            last_active_at: None,
319            last_active_ip: None,
320            human_name: None,
321        })
322    }
323
324    #[tracing::instrument(
325        name = "db.oauth2_session.finish_bulk",
326        skip_all,
327        fields(
328            db.query.text,
329        ),
330    )]
331    async fn finish_bulk(
332        &mut self,
333        clock: &dyn Clock,
334        filter: OAuth2SessionFilter<'_>,
335    ) -> Result<usize, Self::Error> {
336        let finished_at = clock.now();
337        let (sql, arguments) = Query::update()
338            .table(OAuth2Sessions::Table)
339            .value(OAuth2Sessions::FinishedAt, finished_at)
340            .apply_filter(filter)
341            .build_sqlx(PostgresQueryBuilder);
342
343        let res = sqlx::query_with(&sql, arguments)
344            .traced()
345            .execute(&mut *self.conn)
346            .await?;
347
348        Ok(res.rows_affected().try_into().unwrap_or(usize::MAX))
349    }
350
351    #[tracing::instrument(
352        name = "db.oauth2_session.finish",
353        skip_all,
354        fields(
355            db.query.text,
356            %session.id,
357            %session.scope,
358            client.id = %session.client_id,
359        ),
360    )]
361    async fn finish(
362        &mut self,
363        clock: &dyn Clock,
364        session: Session,
365    ) -> Result<Session, Self::Error> {
366        let finished_at = clock.now();
367        let res = sqlx::query!(
368            r#"
369                UPDATE oauth2_sessions
370                SET finished_at = $2
371                WHERE oauth2_session_id = $1
372            "#,
373            Uuid::from(session.id),
374            finished_at,
375        )
376        .traced()
377        .execute(&mut *self.conn)
378        .await?;
379
380        DatabaseError::ensure_affected_rows(&res, 1)?;
381
382        session
383            .finish(finished_at)
384            .map_err(DatabaseError::to_invalid_operation)
385    }
386
387    #[tracing::instrument(
388        name = "db.oauth2_session.list",
389        skip_all,
390        fields(
391            db.query.text,
392        ),
393    )]
394    async fn list(
395        &mut self,
396        filter: OAuth2SessionFilter<'_>,
397        pagination: Pagination,
398    ) -> Result<Page<Session>, Self::Error> {
399        let (sql, arguments) = Query::select()
400            .expr_as(
401                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2SessionId)),
402                OAuthSessionLookupIden::Oauth2SessionId,
403            )
404            .expr_as(
405                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserId)),
406                OAuthSessionLookupIden::UserId,
407            )
408            .expr_as(
409                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserSessionId)),
410                OAuthSessionLookupIden::UserSessionId,
411            )
412            .expr_as(
413                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2ClientId)),
414                OAuthSessionLookupIden::Oauth2ClientId,
415            )
416            .expr_as(
417                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::ScopeList)),
418                OAuthSessionLookupIden::ScopeList,
419            )
420            .expr_as(
421                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::CreatedAt)),
422                OAuthSessionLookupIden::CreatedAt,
423            )
424            .expr_as(
425                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::FinishedAt)),
426                OAuthSessionLookupIden::FinishedAt,
427            )
428            .expr_as(
429                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::UserAgent)),
430                OAuthSessionLookupIden::UserAgent,
431            )
432            .expr_as(
433                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::LastActiveAt)),
434                OAuthSessionLookupIden::LastActiveAt,
435            )
436            .expr_as(
437                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::LastActiveIp)),
438                OAuthSessionLookupIden::LastActiveIp,
439            )
440            .expr_as(
441                Expr::col((OAuth2Sessions::Table, OAuth2Sessions::HumanName)),
442                OAuthSessionLookupIden::HumanName,
443            )
444            .from(OAuth2Sessions::Table)
445            .apply_filter(filter)
446            .generate_pagination(
447                (OAuth2Sessions::Table, OAuth2Sessions::OAuth2SessionId),
448                pagination,
449            )
450            .build_sqlx(PostgresQueryBuilder);
451
452        let edges: Vec<OAuthSessionLookup> = sqlx::query_as_with(&sql, arguments)
453            .traced()
454            .fetch_all(&mut *self.conn)
455            .await?;
456
457        let page = pagination.process(edges).try_map(Session::try_from)?;
458
459        Ok(page)
460    }
461
462    #[tracing::instrument(
463        name = "db.oauth2_session.count",
464        skip_all,
465        fields(
466            db.query.text,
467        ),
468    )]
469    async fn count(&mut self, filter: OAuth2SessionFilter<'_>) -> Result<usize, Self::Error> {
470        let (sql, arguments) = Query::select()
471            .expr(Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2SessionId)).count())
472            .from(OAuth2Sessions::Table)
473            .apply_filter(filter)
474            .build_sqlx(PostgresQueryBuilder);
475
476        let count: i64 = sqlx::query_scalar_with(&sql, arguments)
477            .traced()
478            .fetch_one(&mut *self.conn)
479            .await?;
480
481        count
482            .try_into()
483            .map_err(DatabaseError::to_invalid_operation)
484    }
485
486    #[tracing::instrument(
487        name = "db.oauth2_session.record_batch_activity",
488        skip_all,
489        fields(
490            db.query.text,
491        ),
492    )]
493    async fn record_batch_activity(
494        &mut self,
495        mut activities: Vec<(Ulid, DateTime<Utc>, Option<IpAddr>)>,
496    ) -> Result<(), Self::Error> {
497        // Sort the activity by ID, so that when batching the updates, Postgres
498        // locks the rows in a stable order, preventing deadlocks
499        activities.sort_unstable();
500        let mut ids = Vec::with_capacity(activities.len());
501        let mut last_activities = Vec::with_capacity(activities.len());
502        let mut ips = Vec::with_capacity(activities.len());
503
504        for (id, last_activity, ip) in activities {
505            ids.push(Uuid::from(id));
506            last_activities.push(last_activity);
507            ips.push(ip);
508        }
509
510        let res = sqlx::query!(
511            r#"
512                UPDATE oauth2_sessions
513                SET last_active_at = GREATEST(t.last_active_at, oauth2_sessions.last_active_at)
514                  , last_active_ip = COALESCE(t.last_active_ip, oauth2_sessions.last_active_ip)
515                FROM (
516                    SELECT *
517                    FROM UNNEST($1::uuid[], $2::timestamptz[], $3::inet[])
518                        AS t(oauth2_session_id, last_active_at, last_active_ip)
519                ) AS t
520                WHERE oauth2_sessions.oauth2_session_id = t.oauth2_session_id
521            "#,
522            &ids,
523            &last_activities,
524            &ips as &[Option<IpAddr>],
525        )
526        .traced()
527        .execute(&mut *self.conn)
528        .await?;
529
530        DatabaseError::ensure_affected_rows(&res, ids.len().try_into().unwrap_or(u64::MAX))?;
531
532        Ok(())
533    }
534
535    #[tracing::instrument(
536        name = "db.oauth2_session.record_user_agent",
537        skip_all,
538        fields(
539            db.query.text,
540            %session.id,
541            %session.scope,
542            client.id = %session.client_id,
543            session.user_agent = user_agent,
544        ),
545    )]
546    async fn record_user_agent(
547        &mut self,
548        mut session: Session,
549        user_agent: String,
550    ) -> Result<Session, Self::Error> {
551        let res = sqlx::query!(
552            r#"
553                UPDATE oauth2_sessions
554                SET user_agent = $2
555                WHERE oauth2_session_id = $1
556            "#,
557            Uuid::from(session.id),
558            &*user_agent,
559        )
560        .traced()
561        .execute(&mut *self.conn)
562        .await?;
563
564        session.user_agent = Some(user_agent);
565
566        DatabaseError::ensure_affected_rows(&res, 1)?;
567
568        Ok(session)
569    }
570
571    #[tracing::instrument(
572        name = "repository.oauth2_session.set_human_name",
573        skip(self),
574        fields(
575            client.id = %session.client_id,
576            session.human_name = ?human_name,
577        ),
578    )]
579    async fn set_human_name(
580        &mut self,
581        mut session: Session,
582        human_name: Option<String>,
583    ) -> Result<Session, Self::Error> {
584        let res = sqlx::query!(
585            r#"
586                UPDATE oauth2_sessions
587                SET human_name = $2
588                WHERE oauth2_session_id = $1
589            "#,
590            Uuid::from(session.id),
591            human_name.as_deref(),
592        )
593        .traced()
594        .execute(&mut *self.conn)
595        .await?;
596
597        session.human_name = human_name;
598
599        DatabaseError::ensure_affected_rows(&res, 1)?;
600
601        Ok(session)
602    }
603
604    #[tracing::instrument(
605        name = "db.oauth2_session.cleanup_finished",
606        skip_all,
607        fields(
608            db.query.text,
609            since = since.map(tracing::field::display),
610            until = %until,
611            limit = limit,
612        ),
613    )]
614    async fn cleanup_finished(
615        &mut self,
616        since: Option<DateTime<Utc>>,
617        until: DateTime<Utc>,
618        limit: usize,
619    ) -> Result<(usize, Option<DateTime<Utc>>), Self::Error> {
620        let res = sqlx::query!(
621            r#"
622                WITH
623                    to_delete AS (
624                        SELECT oauth2_session_id, finished_at
625                        FROM oauth2_sessions
626                        WHERE finished_at IS NOT NULL
627                          AND ($1::timestamptz IS NULL OR finished_at >= $1)
628                          AND finished_at < $2
629                        ORDER BY finished_at ASC
630                        LIMIT $3
631                        FOR UPDATE
632                    ),
633                    deleted_refresh_tokens AS (
634                        DELETE FROM oauth2_refresh_tokens USING to_delete
635                        WHERE oauth2_refresh_tokens.oauth2_session_id = to_delete.oauth2_session_id
636                    ),
637                    deleted_access_tokens AS (
638                        DELETE FROM oauth2_access_tokens USING to_delete
639                        WHERE oauth2_access_tokens.oauth2_session_id = to_delete.oauth2_session_id
640                    ),
641                    deleted_sessions AS (
642                        DELETE FROM oauth2_sessions USING to_delete
643                        WHERE oauth2_sessions.oauth2_session_id = to_delete.oauth2_session_id
644                        RETURNING oauth2_sessions.finished_at
645                    )
646                SELECT COUNT(*) as "count!", MAX(finished_at) as last_finished_at FROM deleted_sessions
647            "#,
648            since,
649            until,
650            i64::try_from(limit).unwrap_or(i64::MAX),
651        )
652        .traced()
653        .fetch_one(&mut *self.conn)
654        .await?;
655
656        Ok((
657            res.count.try_into().unwrap_or(usize::MAX),
658            res.last_finished_at,
659        ))
660    }
661
662    #[tracing::instrument(
663        name = "db.oauth2_session.cleanup_inactive_ips",
664        skip_all,
665        fields(
666            db.query.text,
667            since = since.map(tracing::field::display),
668            threshold = %threshold,
669            limit = limit,
670        ),
671    )]
672    async fn cleanup_inactive_ips(
673        &mut self,
674        since: Option<DateTime<Utc>>,
675        threshold: DateTime<Utc>,
676        limit: usize,
677    ) -> Result<(usize, Option<DateTime<Utc>>), Self::Error> {
678        let res = sqlx::query!(
679            r#"
680                WITH to_update AS (
681                    SELECT oauth2_session_id, last_active_at
682                    FROM oauth2_sessions
683                    WHERE last_active_ip IS NOT NULL
684                      AND last_active_at IS NOT NULL
685                      AND ($1::timestamptz IS NULL OR last_active_at >= $1)
686                      AND last_active_at < $2
687                    ORDER BY last_active_at ASC
688                    LIMIT $3
689                    FOR UPDATE
690                ),
691                updated AS (
692                    UPDATE oauth2_sessions
693                    SET last_active_ip = NULL
694                    FROM to_update
695                    WHERE oauth2_sessions.oauth2_session_id = to_update.oauth2_session_id
696                    RETURNING oauth2_sessions.last_active_at
697                )
698                SELECT COUNT(*) AS "count!", MAX(last_active_at) AS last_active_at FROM updated
699            "#,
700            since,
701            threshold,
702            i64::try_from(limit).unwrap_or(i64::MAX),
703        )
704        .traced()
705        .fetch_one(&mut *self.conn)
706        .await?;
707
708        Ok((
709            res.count.try_into().unwrap_or(usize::MAX),
710            res.last_active_at,
711        ))
712    }
713}