Skip to main content

mas_storage_pg/personal/
session.rs

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