1use std::net::IpAddr;
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use mas_data_model::{
13 Authentication, AuthenticationMethod, BrowserSession, Clock, Password, UlidExt as _,
14 UpstreamOAuthAuthorizationSession, User,
15};
16use mas_storage::{
17 Page, Pagination,
18 pagination::Node,
19 user::{BrowserSessionFilter, BrowserSessionRepository},
20};
21use rand::Rng;
22use sea_query::{Expr, ExprTrait, PostgresQueryBuilder, Query};
23use sea_query_sqlx::SqlxBinder;
24use sqlx::PgConnection;
25use ulid::Ulid;
26use uuid::Uuid;
27
28use crate::{
29 DatabaseError, DatabaseInconsistencyError,
30 filter::StatementExt,
31 iden::{UpstreamOAuthAuthorizationSessions, UserSessions, Users},
32 pagination::QueryBuilderExt,
33 tracing::ExecuteExt,
34 ulid_at::{max_ulid_at, min_ulid_at},
35};
36
37pub struct PgBrowserSessionRepository<'c> {
40 conn: &'c mut PgConnection,
41}
42
43impl<'c> PgBrowserSessionRepository<'c> {
44 pub fn new(conn: &'c mut PgConnection) -> Self {
47 Self { conn }
48 }
49}
50
51#[expect(clippy::struct_field_names)]
52#[derive(sqlx::FromRow)]
53#[sea_query::enum_def]
54struct SessionLookup {
55 user_session_id: Uuid,
56 user_session_created_at: DateTime<Utc>,
57 user_session_finished_at: Option<DateTime<Utc>>,
58 user_session_user_agent: Option<String>,
59 user_session_last_active_at: Option<DateTime<Utc>>,
60 user_session_last_active_ip: Option<IpAddr>,
61 user_id: Uuid,
62 user_username: String,
63 user_created_at: DateTime<Utc>,
64 user_locked_at: Option<DateTime<Utc>>,
65 user_deactivated_at: Option<DateTime<Utc>>,
66 user_can_request_admin: bool,
67 user_is_guest: bool,
68}
69
70impl Node<Ulid> for SessionLookup {
71 fn cursor(&self) -> Ulid {
72 self.user_session_id.into()
73 }
74}
75
76impl TryFrom<SessionLookup> for BrowserSession {
77 type Error = DatabaseInconsistencyError;
78
79 fn try_from(value: SessionLookup) -> Result<Self, Self::Error> {
80 let id = Ulid::from(value.user_id);
81 let user = User {
82 id,
83 username: value.user_username,
84 sub: id.to_string(),
85 created_at: value.user_created_at,
86 locked_at: value.user_locked_at,
87 deactivated_at: value.user_deactivated_at,
88 can_request_admin: value.user_can_request_admin,
89 is_guest: value.user_is_guest,
90 };
91
92 Ok(BrowserSession {
93 id: value.user_session_id.into(),
94 user,
95 created_at: value.user_session_created_at,
96 finished_at: value.user_session_finished_at,
97 user_agent: value.user_session_user_agent,
98 last_active_at: value.user_session_last_active_at,
99 last_active_ip: value.user_session_last_active_ip,
100 })
101 }
102}
103
104struct AuthenticationLookup {
105 user_session_authentication_id: Uuid,
106 created_at: DateTime<Utc>,
107 user_password_id: Option<Uuid>,
108 upstream_oauth_authorization_session_id: Option<Uuid>,
109}
110
111impl TryFrom<AuthenticationLookup> for Authentication {
112 type Error = DatabaseInconsistencyError;
113
114 fn try_from(value: AuthenticationLookup) -> Result<Self, Self::Error> {
115 let id = Ulid::from(value.user_session_authentication_id);
116 let authentication_method = match (
117 value.user_password_id.map(Into::into),
118 value
119 .upstream_oauth_authorization_session_id
120 .map(Into::into),
121 ) {
122 (Some(user_password_id), None) => AuthenticationMethod::Password { user_password_id },
123 (None, Some(upstream_oauth2_session_id)) => AuthenticationMethod::UpstreamOAuth2 {
124 upstream_oauth2_session_id,
125 },
126 (None, None) => AuthenticationMethod::Unknown,
127 _ => {
128 return Err(DatabaseInconsistencyError::on("user_session_authentications").row(id));
129 }
130 };
131
132 Ok(Authentication {
133 id,
134 created_at: value.created_at,
135 authentication_method,
136 })
137 }
138}
139
140impl crate::filter::Filter for BrowserSessionFilter<'_> {
141 fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition {
142 sea_query::Condition::all()
143 .add_option(self.user().map(|user| {
144 Expr::col((UserSessions::Table, UserSessions::UserId)).eq(Uuid::from(user.id))
145 }))
146 .add_option(self.state().map(|state| {
147 if state.is_active() {
148 Expr::col((UserSessions::Table, UserSessions::FinishedAt)).is_null()
149 } else {
150 Expr::col((UserSessions::Table, UserSessions::FinishedAt)).is_not_null()
151 }
152 }))
153 .add_option(self.last_active_after().map(|last_active_after| {
154 Expr::col((UserSessions::Table, UserSessions::LastActiveAt)).gt(last_active_after)
155 }))
156 .add_option(self.last_active_before().map(|last_active_before| {
157 Expr::col((UserSessions::Table, UserSessions::LastActiveAt)).lt(last_active_before)
158 }))
159 .add_option(self.created_after().map(|created_after| {
160 Expr::col((UserSessions::Table, UserSessions::UserSessionId))
164 .gt(max_ulid_at(created_after))
165 }))
166 .add_option(self.created_before().map(|created_before| {
167 Expr::col((UserSessions::Table, UserSessions::UserSessionId))
168 .lt(min_ulid_at(created_before))
169 }))
170 .add_option(self.linked_to_upstream_sessions().map(|filter| {
171 Expr::col((UserSessions::Table, UserSessions::UserSessionId)).in_subquery(
172 Query::select()
173 .expr(Expr::col((
174 UpstreamOAuthAuthorizationSessions::Table,
175 UpstreamOAuthAuthorizationSessions::UserSessionId,
176 )))
177 .from(UpstreamOAuthAuthorizationSessions::Table)
178 .apply_filter(filter)
179 .take(),
180 )
181 }))
182 }
183}
184
185#[async_trait]
186impl BrowserSessionRepository for PgBrowserSessionRepository<'_> {
187 type Error = DatabaseError;
188
189 #[tracing::instrument(
190 name = "db.browser_session.lookup",
191 skip_all,
192 fields(
193 db.query.text,
194 user_session.id = %id,
195 ),
196 )]
197 async fn lookup(&mut self, id: Ulid) -> Result<Option<BrowserSession>, Self::Error> {
198 let res = sqlx::query_as!(
199 SessionLookup,
200 r#"
201 SELECT s.user_session_id
202 , s.created_at AS "user_session_created_at"
203 , s.finished_at AS "user_session_finished_at"
204 , s.user_agent AS "user_session_user_agent"
205 , s.last_active_at AS "user_session_last_active_at"
206 , s.last_active_ip AS "user_session_last_active_ip: IpAddr"
207 , u.user_id
208 , u.username AS "user_username"
209 , u.created_at AS "user_created_at"
210 , u.locked_at AS "user_locked_at"
211 , u.deactivated_at AS "user_deactivated_at"
212 , u.can_request_admin AS "user_can_request_admin"
213 , u.is_guest AS "user_is_guest"
214 FROM user_sessions s
215 INNER JOIN users u
216 USING (user_id)
217 WHERE s.user_session_id = $1
218 "#,
219 Uuid::from(id),
220 )
221 .traced()
222 .fetch_optional(&mut *self.conn)
223 .await?;
224
225 let Some(res) = res else { return Ok(None) };
226
227 Ok(Some(res.try_into()?))
228 }
229
230 #[tracing::instrument(
231 name = "db.browser_session.add",
232 skip_all,
233 fields(
234 db.query.text,
235 %user.id,
236 user_session.id,
237 ),
238 )]
239 async fn add(
240 &mut self,
241 rng: &mut (dyn Rng + Send),
242 clock: &dyn Clock,
243 user: &User,
244 user_agent: Option<String>,
245 ) -> Result<BrowserSession, Self::Error> {
246 let created_at = clock.now();
247 let id = Ulid::from_datetime_with_rng(created_at, rng);
248 tracing::Span::current().record("user_session.id", tracing::field::display(id));
249
250 sqlx::query!(
251 r#"
252 INSERT INTO user_sessions (user_session_id, user_id, created_at, user_agent)
253 VALUES ($1, $2, $3, $4)
254 "#,
255 Uuid::from(id),
256 Uuid::from(user.id),
257 created_at,
258 user_agent.as_deref(),
259 )
260 .traced()
261 .execute(&mut *self.conn)
262 .await?;
263
264 let session = BrowserSession {
265 id,
266 user: user.clone(),
268 created_at,
269 finished_at: None,
270 user_agent,
271 last_active_at: None,
272 last_active_ip: None,
273 };
274
275 Ok(session)
276 }
277
278 #[tracing::instrument(
279 name = "db.browser_session.finish",
280 skip_all,
281 fields(
282 db.query.text,
283 %user_session.id,
284 ),
285 )]
286 async fn finish(
287 &mut self,
288 clock: &dyn Clock,
289 mut user_session: BrowserSession,
290 ) -> Result<BrowserSession, Self::Error> {
291 let finished_at = clock.now();
292 let res = sqlx::query!(
293 r#"
294 UPDATE user_sessions
295 SET finished_at = $1
296 WHERE user_session_id = $2
297 "#,
298 finished_at,
299 Uuid::from(user_session.id),
300 )
301 .traced()
302 .execute(&mut *self.conn)
303 .await?;
304
305 user_session.finished_at = Some(finished_at);
306
307 DatabaseError::ensure_affected_rows(&res, 1)?;
308
309 Ok(user_session)
310 }
311
312 #[tracing::instrument(
313 name = "db.browser_session.finish_bulk",
314 skip_all,
315 fields(
316 db.query.text,
317 ),
318 )]
319 async fn finish_bulk(
320 &mut self,
321 clock: &dyn Clock,
322 filter: BrowserSessionFilter<'_>,
323 ) -> Result<usize, Self::Error> {
324 let finished_at = clock.now();
325 let (sql, arguments) = sea_query::Query::update()
326 .table(UserSessions::Table)
327 .value(UserSessions::FinishedAt, finished_at)
328 .apply_filter(filter)
329 .build_sqlx(PostgresQueryBuilder);
330
331 let res = sqlx::query_with(&sql, arguments)
332 .traced()
333 .execute(&mut *self.conn)
334 .await?;
335
336 Ok(res.rows_affected().try_into().unwrap_or(usize::MAX))
337 }
338
339 #[tracing::instrument(
340 name = "db.browser_session.list",
341 skip_all,
342 fields(
343 db.query.text,
344 ),
345 )]
346 async fn list(
347 &mut self,
348 filter: BrowserSessionFilter<'_>,
349 pagination: Pagination,
350 ) -> Result<Page<BrowserSession>, Self::Error> {
351 let (sql, arguments) = sea_query::Query::select()
352 .expr_as(
353 Expr::col((UserSessions::Table, UserSessions::UserSessionId)),
354 SessionLookupIden::UserSessionId,
355 )
356 .expr_as(
357 Expr::col((UserSessions::Table, UserSessions::CreatedAt)),
358 SessionLookupIden::UserSessionCreatedAt,
359 )
360 .expr_as(
361 Expr::col((UserSessions::Table, UserSessions::FinishedAt)),
362 SessionLookupIden::UserSessionFinishedAt,
363 )
364 .expr_as(
365 Expr::col((UserSessions::Table, UserSessions::UserAgent)),
366 SessionLookupIden::UserSessionUserAgent,
367 )
368 .expr_as(
369 Expr::col((UserSessions::Table, UserSessions::LastActiveAt)),
370 SessionLookupIden::UserSessionLastActiveAt,
371 )
372 .expr_as(
373 Expr::col((UserSessions::Table, UserSessions::LastActiveIp)),
374 SessionLookupIden::UserSessionLastActiveIp,
375 )
376 .expr_as(
377 Expr::col((Users::Table, Users::UserId)),
378 SessionLookupIden::UserId,
379 )
380 .expr_as(
381 Expr::col((Users::Table, Users::Username)),
382 SessionLookupIden::UserUsername,
383 )
384 .expr_as(
385 Expr::col((Users::Table, Users::CreatedAt)),
386 SessionLookupIden::UserCreatedAt,
387 )
388 .expr_as(
389 Expr::col((Users::Table, Users::LockedAt)),
390 SessionLookupIden::UserLockedAt,
391 )
392 .expr_as(
393 Expr::col((Users::Table, Users::DeactivatedAt)),
394 SessionLookupIden::UserDeactivatedAt,
395 )
396 .expr_as(
397 Expr::col((Users::Table, Users::CanRequestAdmin)),
398 SessionLookupIden::UserCanRequestAdmin,
399 )
400 .expr_as(
401 Expr::col((Users::Table, Users::IsGuest)),
402 SessionLookupIden::UserIsGuest,
403 )
404 .from(UserSessions::Table)
405 .inner_join(
406 Users::Table,
407 Expr::col((UserSessions::Table, UserSessions::UserId))
408 .equals((Users::Table, Users::UserId)),
409 )
410 .apply_filter(filter)
411 .generate_pagination(
412 (UserSessions::Table, UserSessions::UserSessionId),
413 pagination,
414 )
415 .build_sqlx(PostgresQueryBuilder);
416
417 let edges: Vec<SessionLookup> = sqlx::query_as_with(&sql, arguments)
418 .traced()
419 .fetch_all(&mut *self.conn)
420 .await?;
421
422 let page = pagination
423 .process(edges)
424 .try_map(BrowserSession::try_from)?;
425
426 Ok(page)
427 }
428
429 #[tracing::instrument(
430 name = "db.browser_session.count",
431 skip_all,
432 fields(
433 db.query.text,
434 ),
435 )]
436 async fn count(&mut self, filter: BrowserSessionFilter<'_>) -> Result<usize, Self::Error> {
437 let (sql, arguments) = sea_query::Query::select()
438 .expr(Expr::col((UserSessions::Table, UserSessions::UserSessionId)).count())
439 .from(UserSessions::Table)
440 .apply_filter(filter)
441 .build_sqlx(PostgresQueryBuilder);
442
443 let count: i64 = sqlx::query_scalar_with(&sql, arguments)
444 .traced()
445 .fetch_one(&mut *self.conn)
446 .await?;
447
448 count
449 .try_into()
450 .map_err(DatabaseError::to_invalid_operation)
451 }
452
453 #[tracing::instrument(
454 name = "db.browser_session.authenticate_with_password",
455 skip_all,
456 fields(
457 db.query.text,
458 %user_session.id,
459 %user_password.id,
460 user_session_authentication.id,
461 ),
462 )]
463 async fn authenticate_with_password(
464 &mut self,
465 rng: &mut (dyn Rng + Send),
466 clock: &dyn Clock,
467 user_session: &BrowserSession,
468 user_password: &Password,
469 ) -> Result<Authentication, Self::Error> {
470 let created_at = clock.now();
471 let id = Ulid::from_datetime_with_rng(created_at, rng);
472 tracing::Span::current().record(
473 "user_session_authentication.id",
474 tracing::field::display(id),
475 );
476
477 sqlx::query!(
478 r#"
479 INSERT INTO user_session_authentications
480 (user_session_authentication_id, user_session_id, created_at, user_password_id)
481 VALUES ($1, $2, $3, $4)
482 "#,
483 Uuid::from(id),
484 Uuid::from(user_session.id),
485 created_at,
486 Uuid::from(user_password.id),
487 )
488 .traced()
489 .execute(&mut *self.conn)
490 .await?;
491
492 Ok(Authentication {
493 id,
494 created_at,
495 authentication_method: AuthenticationMethod::Password {
496 user_password_id: user_password.id,
497 },
498 })
499 }
500
501 #[tracing::instrument(
502 name = "db.browser_session.authenticate_with_upstream",
503 skip_all,
504 fields(
505 db.query.text,
506 %user_session.id,
507 %upstream_oauth_session.id,
508 user_session_authentication.id,
509 ),
510 )]
511 async fn authenticate_with_upstream(
512 &mut self,
513 rng: &mut (dyn Rng + Send),
514 clock: &dyn Clock,
515 user_session: &BrowserSession,
516 upstream_oauth_session: &UpstreamOAuthAuthorizationSession,
517 ) -> Result<Authentication, Self::Error> {
518 let created_at = clock.now();
519 let id = Ulid::from_datetime_with_rng(created_at, rng);
520 tracing::Span::current().record(
521 "user_session_authentication.id",
522 tracing::field::display(id),
523 );
524
525 sqlx::query!(
526 r#"
527 INSERT INTO user_session_authentications
528 (user_session_authentication_id, user_session_id, created_at, upstream_oauth_authorization_session_id)
529 VALUES ($1, $2, $3, $4)
530 "#,
531 Uuid::from(id),
532 Uuid::from(user_session.id),
533 created_at,
534 Uuid::from(upstream_oauth_session.id),
535 )
536 .traced()
537 .execute(&mut *self.conn)
538 .await?;
539
540 Ok(Authentication {
541 id,
542 created_at,
543 authentication_method: AuthenticationMethod::UpstreamOAuth2 {
544 upstream_oauth2_session_id: upstream_oauth_session.id,
545 },
546 })
547 }
548
549 #[tracing::instrument(
550 name = "db.browser_session.get_last_authentication",
551 skip_all,
552 fields(
553 db.query.text,
554 %user_session.id,
555 ),
556 )]
557 async fn get_last_authentication(
558 &mut self,
559 user_session: &BrowserSession,
560 ) -> Result<Option<Authentication>, Self::Error> {
561 let authentication = sqlx::query_as!(
562 AuthenticationLookup,
563 r#"
564 SELECT user_session_authentication_id
565 , created_at
566 , user_password_id
567 , upstream_oauth_authorization_session_id
568 FROM user_session_authentications
569 WHERE user_session_id = $1
570 ORDER BY created_at DESC
571 LIMIT 1
572 "#,
573 Uuid::from(user_session.id),
574 )
575 .traced()
576 .fetch_optional(&mut *self.conn)
577 .await?;
578
579 let Some(authentication) = authentication else {
580 return Ok(None);
581 };
582
583 let authentication = Authentication::try_from(authentication)?;
584 Ok(Some(authentication))
585 }
586
587 #[tracing::instrument(
588 name = "db.browser_session.record_batch_activity",
589 skip_all,
590 fields(
591 db.query.text,
592 ),
593 )]
594 async fn record_batch_activity(
595 &mut self,
596 mut activities: Vec<(Ulid, DateTime<Utc>, Option<IpAddr>)>,
597 ) -> Result<(), Self::Error> {
598 activities.sort_unstable();
601 let mut ids = Vec::with_capacity(activities.len());
602 let mut last_activities = Vec::with_capacity(activities.len());
603 let mut ips = Vec::with_capacity(activities.len());
604
605 for (id, last_activity, ip) in activities {
606 ids.push(Uuid::from(id));
607 last_activities.push(last_activity);
608 ips.push(ip);
609 }
610
611 let res = sqlx::query!(
612 r#"
613 UPDATE user_sessions
614 SET last_active_at = GREATEST(t.last_active_at, user_sessions.last_active_at)
615 , last_active_ip = COALESCE(t.last_active_ip, user_sessions.last_active_ip)
616 FROM (
617 SELECT *
618 FROM UNNEST($1::uuid[], $2::timestamptz[], $3::inet[])
619 AS t(user_session_id, last_active_at, last_active_ip)
620 ) AS t
621 WHERE user_sessions.user_session_id = t.user_session_id
622 "#,
623 &ids,
624 &last_activities,
625 &ips as &[Option<IpAddr>],
626 )
627 .traced()
628 .execute(&mut *self.conn)
629 .await?;
630
631 DatabaseError::ensure_affected_rows(&res, ids.len().try_into().unwrap_or(u64::MAX))?;
632
633 Ok(())
634 }
635
636 #[tracing::instrument(
637 name = "db.browser_session.cleanup_finished",
638 skip_all,
639 fields(
640 db.query.text,
641 since = since.map(tracing::field::display),
642 until = %until,
643 limit = limit,
644 ),
645 )]
646 async fn cleanup_finished(
647 &mut self,
648 since: Option<DateTime<Utc>>,
649 until: DateTime<Utc>,
650 limit: usize,
651 ) -> Result<(usize, Option<DateTime<Utc>>), Self::Error> {
652 let res = sqlx::query!(
653 r#"
654 WITH
655 to_delete AS (
656 SELECT user_session_id, finished_at
657 FROM user_sessions us
658 WHERE us.finished_at IS NOT NULL
659 AND ($1::timestamptz IS NULL OR us.finished_at >= $1)
660 AND us.finished_at < $2
661 -- Only delete if no oauth2_sessions reference this user_session
662 AND NOT EXISTS (
663 SELECT 1 FROM oauth2_sessions os
664 WHERE os.user_session_id = us.user_session_id
665 )
666 -- Only delete if no compat_sessions reference this user_session
667 AND NOT EXISTS (
668 SELECT 1 FROM compat_sessions cs
669 WHERE cs.user_session_id = us.user_session_id
670 )
671 ORDER BY us.finished_at ASC
672 LIMIT $3
673 FOR UPDATE OF us
674 ),
675 deleted_authentications AS (
676 DELETE FROM user_session_authentications USING to_delete
677 WHERE user_session_authentications.user_session_id = to_delete.user_session_id
678 ),
679 deleted_sessions AS (
680 DELETE FROM user_sessions USING to_delete
681 WHERE user_sessions.user_session_id = to_delete.user_session_id
682 RETURNING user_sessions.finished_at
683 )
684 SELECT COUNT(*) as "count!", MAX(finished_at) as last_finished_at FROM deleted_sessions
685 "#,
686 since,
687 until,
688 i64::try_from(limit).unwrap_or(i64::MAX),
689 )
690 .traced()
691 .fetch_one(&mut *self.conn)
692 .await?;
693
694 Ok((
695 res.count.try_into().unwrap_or(usize::MAX),
696 res.last_finished_at,
697 ))
698 }
699
700 #[tracing::instrument(
701 name = "db.browser_session.cleanup_inactive_ips",
702 skip_all,
703 fields(
704 db.query.text,
705 since = since.map(tracing::field::display),
706 threshold = %threshold,
707 limit = limit,
708 ),
709 )]
710 async fn cleanup_inactive_ips(
711 &mut self,
712 since: Option<DateTime<Utc>>,
713 threshold: DateTime<Utc>,
714 limit: usize,
715 ) -> Result<(usize, Option<DateTime<Utc>>), Self::Error> {
716 let res = sqlx::query!(
717 r#"
718 WITH to_update AS (
719 SELECT user_session_id, last_active_at
720 FROM user_sessions
721 WHERE last_active_ip IS NOT NULL
722 AND last_active_at IS NOT NULL
723 AND ($1::timestamptz IS NULL OR last_active_at >= $1)
724 AND last_active_at < $2
725 ORDER BY last_active_at ASC
726 LIMIT $3
727 FOR UPDATE
728 ),
729 updated AS (
730 UPDATE user_sessions
731 SET last_active_ip = NULL
732 FROM to_update
733 WHERE user_sessions.user_session_id = to_update.user_session_id
734 RETURNING user_sessions.last_active_at
735 )
736 SELECT COUNT(*) AS "count!", MAX(last_active_at) AS last_active_at FROM updated
737 "#,
738 since,
739 threshold,
740 i64::try_from(limit).unwrap_or(i64::MAX),
741 )
742 .traced()
743 .fetch_one(&mut *self.conn)
744 .await?;
745
746 Ok((
747 res.count.try_into().unwrap_or(usize::MAX),
748 res.last_active_at,
749 ))
750 }
751}