1use 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
45pub struct PgPersonalSessionRepository<'c> {
48 conn: &'c mut PgConnection,
49}
50
51impl<'c> PgPersonalSessionRepository<'c> {
52 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 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 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 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 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 .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 .add(
392 Expr::col((
393 PersonalSessions::Table,
394 PersonalSessions::PersonalSessionId,
395 ))
396 .eq(Expr::col((
397 PersonalAccessTokens::Table,
398 PersonalAccessTokens::PersonalSessionId,
399 ))),
400 )
401 .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 .add(
501 Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId))
502 .eq(Expr::col((
503 PersonalAccessTokens::Table,
504 PersonalAccessTokens::PersonalSessionId,
505 ))),
506 )
507 .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 .add(
546 Expr::col((PersonalSessions::Table, PersonalSessions::PersonalSessionId))
547 .eq(Expr::col((
548 PersonalAccessTokens::Table,
549 PersonalAccessTokens::PersonalSessionId,
550 ))),
551 )
552 .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 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 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}