From f919fc45c7ace98be9f284c7bff3bde666c30d41 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 15:45:52 +0200 Subject: [PATCH 01/11] storage-pg: add min/max ulid helpers for time-based filters --- crates/storage-pg/src/lib.rs | 2 + crates/storage-pg/src/ulid_at.rs | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 crates/storage-pg/src/ulid_at.rs diff --git a/crates/storage-pg/src/lib.rs b/crates/storage-pg/src/lib.rs index 0e649bb81..4d11cd8c8 100644 --- a/crates/storage-pg/src/lib.rs +++ b/crates/storage-pg/src/lib.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -186,6 +187,7 @@ pub(crate) mod policy_data; pub(crate) mod repository; pub(crate) mod telemetry; pub(crate) mod tracing; +pub(crate) mod ulid_at; pub(crate) use self::errors::DatabaseInconsistencyError; pub use self::{ diff --git a/crates/storage-pg/src/ulid_at.rs b/crates/storage-pg/src/ulid_at.rs new file mode 100644 index 000000000..2df36f347 --- /dev/null +++ b/crates/storage-pg/src/ulid_at.rs @@ -0,0 +1,80 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Helpers to translate "created before/after a timestamp" filters into +//! ULID-based primary key comparisons. +//! +//! ULIDs encode their creation timestamp (in milliseconds) in their high 48 +//! bits. Because session tables use ULIDs as their primary key, we can filter +//! on a creation time by comparing the primary key against a synthetic ULID +//! with the same timestamp and either the smallest or largest possible random +//! component. This lets the existing primary key B-tree index serve the query +//! without adding a separate `created_at` index. + +use chrono::{DateTime, Utc}; +use ulid::Ulid; +use uuid::Uuid; + +/// Build the smallest possible [`Uuid`] for a ULID generated at the given time. +/// +/// Use this as the upper bound when filtering rows created strictly *before* +/// `t`: `id < min_ulid_at(t)`. +pub(crate) fn min_ulid_at(t: DateTime) -> Uuid { + let timestamp = u64::try_from(t.timestamp_millis()).unwrap_or(u64::MIN); + Ulid::from_parts(timestamp, 0).into() +} + +/// Build the largest possible [`Uuid`] for a ULID generated at the given time. +/// +/// Use this as the lower bound when filtering rows created strictly *after* +/// `t`: `id > max_ulid_at(t)`. +pub(crate) fn max_ulid_at(t: DateTime) -> Uuid { + let timestamp = u64::try_from(t.timestamp_millis()).unwrap_or(u64::MIN); + Ulid::from_parts(timestamp, u128::MAX).into() +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + use ulid::Ulid; + + use super::{max_ulid_at, min_ulid_at}; + + #[test] + fn min_is_less_than_max_at_same_time() { + let t = chrono::Utc + .with_ymd_and_hms(2024, 6, 1, 12, 34, 56) + .unwrap(); + let min = min_ulid_at(t); + let max = max_ulid_at(t); + assert!(min < max, "min ({min}) should be less than max ({max})"); + } + + #[test] + fn timestamp_roundtrips() { + let t = chrono::Utc + .with_ymd_and_hms(2024, 6, 1, 12, 34, 56) + .unwrap() + + chrono::Duration::milliseconds(789); + let expected_ms = u64::try_from(t.timestamp_millis()).unwrap(); + + let min_ulid: Ulid = min_ulid_at(t).into(); + let max_ulid: Ulid = max_ulid_at(t).into(); + + assert_eq!(min_ulid.timestamp_ms(), expected_ms); + assert_eq!(max_ulid.timestamp_ms(), expected_ms); + // The random component of a ULID is 80 bits wide, so `u128::MAX` is + // truncated to the largest 80-bit value. + assert_eq!(min_ulid.random(), 0); + assert_eq!(max_ulid.random(), (1u128 << 80) - 1); + } + + #[test] + fn distinct_times_produce_distinct_bounds() { + let earlier = chrono::Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap(); + let later = chrono::Utc.with_ymd_and_hms(2024, 6, 1, 13, 0, 0).unwrap(); + assert!(max_ulid_at(earlier) < min_ulid_at(later)); + } +} From 798c0b9c482c81b3e98ba4b5ebac9524d5112995 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 15:45:57 +0200 Subject: [PATCH 02/11] storage: filter OAuth2 sessions by creation time --- crates/storage-pg/src/oauth2/mod.rs | 97 +++++++++++++++++++++++++ crates/storage-pg/src/oauth2/session.rs | 12 +++ crates/storage/src/oauth2/session.rs | 32 ++++++++ 3 files changed, 141 insertions(+) diff --git a/crates/storage-pg/src/oauth2/mod.rs b/crates/storage-pg/src/oauth2/mod.rs index bf741b5f2..90e066f0f 100644 --- a/crates/storage-pg/src/oauth2/mod.rs +++ b/crates/storage-pg/src/oauth2/mod.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2021-2024 The Matrix.org Foundation C.I.C. // @@ -720,6 +721,102 @@ mod tests { ); } + /// Test the created-at filters on [`OAuth2SessionFilter`]. + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_list_sessions_by_created_at(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap().boxed(); + + let user = repo + .user() + .add(&mut rng, &clock, "alice".to_owned()) + .await + .unwrap(); + let user_session = repo + .browser_session() + .add(&mut rng, &clock, &user, None) + .await + .unwrap(); + let client = repo + .oauth2_client() + .add( + &mut rng, + &clock, + vec!["https://example.com/redirect".parse().unwrap()], + None, + None, + None, + vec![GrantType::AuthorizationCode], + Some("Test client".to_owned()), + Some("https://example.com/logo.png".parse().unwrap()), + Some("https://example.com/".parse().unwrap()), + Some("https://example.com/policy".parse().unwrap()), + Some("https://example.com/tos".parse().unwrap()), + Some("https://example.com/jwks.json".parse().unwrap()), + None, + None, + None, + None, + None, + Some("https://example.com/login".parse().unwrap()), + ) + .await + .unwrap(); + + let scope = Scope::from_iter([OPENID]); + + // Create three sessions, one per minute, capturing the cutoff timestamp + // between the second and the third. + let session1 = repo + .oauth2_session() + .add_from_browser_session(&mut rng, &clock, &client, &user_session, scope.clone()) + .await + .unwrap(); + clock.advance(Duration::try_minutes(1).unwrap()); + + let session2 = repo + .oauth2_session() + .add_from_browser_session(&mut rng, &clock, &client, &user_session, scope.clone()) + .await + .unwrap(); + clock.advance(Duration::try_minutes(1).unwrap()); + + let cutoff = clock.now(); + + clock.advance(Duration::try_minutes(1).unwrap()); + let session3 = repo + .oauth2_session() + .add_from_browser_session(&mut rng, &clock, &client, &user_session, scope.clone()) + .await + .unwrap(); + + let pagination = Pagination::first(10); + + // Sessions created before the cutoff + let filter = OAuth2SessionFilter::new().with_created_before(cutoff); + let list = repo + .oauth2_session() + .list(filter, pagination) + .await + .unwrap(); + assert_eq!(list.edges.len(), 2); + assert_eq!(list.edges[0].node, session1); + assert_eq!(list.edges[1].node, session2); + assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 2); + + // Sessions created after the cutoff + let filter = OAuth2SessionFilter::new().with_created_after(cutoff); + let list = repo + .oauth2_session() + .list(filter, pagination) + .await + .unwrap(); + assert_eq!(list.edges.len(), 1); + assert_eq!(list.edges[0].node, session3); + assert_eq!(repo.oauth2_session().count(filter).await.unwrap(), 1); + } + /// Test the [`OAuth2DeviceCodeGrantRepository`] implementation #[sqlx::test(migrator = "crate::MIGRATOR")] async fn test_device_code_grant_repository(pool: PgPool) { diff --git a/crates/storage-pg/src/oauth2/session.rs b/crates/storage-pg/src/oauth2/session.rs index e1379f74a..b1aa554de 100644 --- a/crates/storage-pg/src/oauth2/session.rs +++ b/crates/storage-pg/src/oauth2/session.rs @@ -32,6 +32,7 @@ use crate::{ iden::{OAuth2Clients, OAuth2Sessions, UserSessions}, pagination::QueryBuilderExt, tracing::ExecuteExt, + ulid_at::{max_ulid_at, min_ulid_at}, }; /// An implementation of [`OAuth2SessionRepository`] for a PostgreSQL connection @@ -197,6 +198,17 @@ impl Filter for OAuth2SessionFilter<'_> { Expr::col((OAuth2Sessions::Table, OAuth2Sessions::LastActiveAt)) .lt(last_active_before) })) + .add_option(self.created_after().map(|created_after| { + // ULIDs encode the creation time in their high 48 bits, so we + // can use the primary key index to filter on creation time + // without touching the `created_at` column. + Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2SessionId)) + .gt(max_ulid_at(created_after)) + })) + .add_option(self.created_before().map(|created_before| { + Expr::col((OAuth2Sessions::Table, OAuth2Sessions::OAuth2SessionId)) + .lt(min_ulid_at(created_before)) + })) } } diff --git a/crates/storage/src/oauth2/session.rs b/crates/storage/src/oauth2/session.rs index 75035653f..024771cdb 100644 --- a/crates/storage/src/oauth2/session.rs +++ b/crates/storage/src/oauth2/session.rs @@ -58,6 +58,8 @@ pub struct OAuth2SessionFilter<'a> { scope: Option<&'a Scope>, last_active_before: Option>, last_active_after: Option>, + created_before: Option>, + created_after: Option>, } impl<'a> OAuth2SessionFilter<'a> { @@ -204,6 +206,36 @@ impl<'a> OAuth2SessionFilter<'a> { self.last_active_after } + /// Only return sessions created before the given time + #[must_use] + pub fn with_created_before(mut self, created_before: DateTime) -> Self { + self.created_before = Some(created_before); + self + } + + /// Only return sessions created after the given time + #[must_use] + pub fn with_created_after(mut self, created_after: DateTime) -> Self { + self.created_after = Some(created_after); + self + } + + /// Get the created-before filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn created_before(&self) -> Option> { + self.created_before + } + + /// Get the created-after filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn created_after(&self) -> Option> { + self.created_after + } + /// Only return active sessions #[must_use] pub fn active_only(mut self) -> Self { From da8faafe3b5a87d40f076d135cdcd8ea40ef8388 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 15:47:39 +0200 Subject: [PATCH 03/11] storage: filter compat sessions by creation time --- crates/storage-pg/src/compat/mod.rs | 68 +++++++++++++++++++++++++ crates/storage-pg/src/compat/session.rs | 12 +++++ crates/storage/src/compat/session.rs | 32 ++++++++++++ 3 files changed, 112 insertions(+) diff --git a/crates/storage-pg/src/compat/mod.rs b/crates/storage-pg/src/compat/mod.rs index b9ce82da4..83d3522b4 100644 --- a/crates/storage-pg/src/compat/mod.rs +++ b/crates/storage-pg/src/compat/mod.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -312,6 +313,73 @@ mod tests { assert_eq!(repo.compat_session().count(active).await.unwrap(), 0); } + /// Test the created-at filters on [`CompatSessionFilter`]. + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_list_compat_sessions_by_created_at(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + + let user = repo + .user() + .add(&mut rng, &clock, "alice".to_owned()) + .await + .unwrap(); + + // Three sessions created one minute apart, with a cutoff captured + // between the second and the third. + let device = Device::generate(&mut rng); + let session1 = repo + .compat_session() + .add(&mut rng, &clock, &user, device, None, false, None) + .await + .unwrap(); + clock.advance(Duration::try_minutes(1).unwrap()); + + let device = Device::generate(&mut rng); + let session2 = repo + .compat_session() + .add(&mut rng, &clock, &user, device, None, false, None) + .await + .unwrap(); + clock.advance(Duration::try_minutes(1).unwrap()); + + let cutoff = clock.now(); + + clock.advance(Duration::try_minutes(1).unwrap()); + let device = Device::generate(&mut rng); + let session3 = repo + .compat_session() + .add(&mut rng, &clock, &user, device, None, false, None) + .await + .unwrap(); + + let pagination = Pagination::first(10); + + // Sessions created before the cutoff + let filter = CompatSessionFilter::new().with_created_before(cutoff); + let list = repo + .compat_session() + .list(filter, pagination) + .await + .unwrap(); + assert_eq!(list.edges.len(), 2); + assert_eq!(list.edges[0].node.0, session1); + assert_eq!(list.edges[1].node.0, session2); + assert_eq!(repo.compat_session().count(filter).await.unwrap(), 2); + + // Sessions created after the cutoff + let filter = CompatSessionFilter::new().with_created_after(cutoff); + let list = repo + .compat_session() + .list(filter, pagination) + .await + .unwrap(); + assert_eq!(list.edges.len(), 1); + assert_eq!(list.edges[0].node.0, session3); + assert_eq!(repo.compat_session().count(filter).await.unwrap(), 1); + } + #[sqlx::test(migrator = "crate::MIGRATOR")] async fn test_access_token_repository(pool: PgPool) { const FIRST_TOKEN: &str = "first_access_token"; diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index e7612fdc1..36768c3b4 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -32,6 +32,7 @@ use crate::{ iden::{CompatSessions, CompatSsoLogins, UserSessions}, pagination::QueryBuilderExt, tracing::ExecuteExt, + ulid_at::{max_ulid_at, min_ulid_at}, }; /// An implementation of [`CompatSessionRepository`] for a PostgreSQL connection @@ -263,6 +264,17 @@ impl Filter for CompatSessionFilter<'_> { Expr::col((CompatSessions::Table, CompatSessions::LastActiveAt)) .lt(last_active_before) })) + .add_option(self.created_after().map(|created_after| { + // ULIDs encode the creation time in their high 48 bits, so we + // can use the primary key index to filter on creation time + // without touching the `created_at` column. + Expr::col((CompatSessions::Table, CompatSessions::CompatSessionId)) + .gt(max_ulid_at(created_after)) + })) + .add_option(self.created_before().map(|created_before| { + Expr::col((CompatSessions::Table, CompatSessions::CompatSessionId)) + .lt(min_ulid_at(created_before)) + })) .add_option(self.device().map(|device| { Expr::col((CompatSessions::Table, CompatSessions::DeviceId)).eq(device.as_str()) })) diff --git a/crates/storage/src/compat/session.rs b/crates/storage/src/compat/session.rs index 8ea08d8f6..9149e4953 100644 --- a/crates/storage/src/compat/session.rs +++ b/crates/storage/src/compat/session.rs @@ -66,6 +66,8 @@ pub struct CompatSessionFilter<'a> { device: Option<&'a Device>, last_active_before: Option>, last_active_after: Option>, + created_before: Option>, + created_after: Option>, } impl<'a> CompatSessionFilter<'a> { @@ -160,6 +162,36 @@ impl<'a> CompatSessionFilter<'a> { self.last_active_after } + /// Only return sessions created before the given time + #[must_use] + pub fn with_created_before(mut self, created_before: DateTime) -> Self { + self.created_before = Some(created_before); + self + } + + /// Only return sessions created after the given time + #[must_use] + pub fn with_created_after(mut self, created_after: DateTime) -> Self { + self.created_after = Some(created_after); + self + } + + /// Get the created-before filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn created_before(&self) -> Option> { + self.created_before + } + + /// Get the created-after filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn created_after(&self) -> Option> { + self.created_after + } + /// Only return active compatibility sessions #[must_use] pub fn active_only(mut self) -> Self { From d61ddd832c1576c90171c6e1b7673f3dbc4ff7a7 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 15:49:35 +0200 Subject: [PATCH 04/11] storage: filter browser sessions by creation time --- crates/storage-pg/src/user/session.rs | 12 +++++ crates/storage-pg/src/user/tests.rs | 64 +++++++++++++++++++++++++++ crates/storage/src/user/session.rs | 32 ++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/crates/storage-pg/src/user/session.rs b/crates/storage-pg/src/user/session.rs index 069db8d90..5fc727c69 100644 --- a/crates/storage-pg/src/user/session.rs +++ b/crates/storage-pg/src/user/session.rs @@ -31,6 +31,7 @@ use crate::{ iden::{UpstreamOAuthAuthorizationSessions, UserSessions, Users}, pagination::QueryBuilderExt, tracing::ExecuteExt, + ulid_at::{max_ulid_at, min_ulid_at}, }; /// An implementation of [`BrowserSessionRepository`] for a PostgreSQL @@ -155,6 +156,17 @@ impl crate::filter::Filter for BrowserSessionFilter<'_> { .add_option(self.last_active_before().map(|last_active_before| { Expr::col((UserSessions::Table, UserSessions::LastActiveAt)).lt(last_active_before) })) + .add_option(self.created_after().map(|created_after| { + // ULIDs encode the creation time in their high 48 bits, so we + // can use the primary key index to filter on creation time + // without touching the `created_at` column. + Expr::col((UserSessions::Table, UserSessions::UserSessionId)) + .gt(max_ulid_at(created_after)) + })) + .add_option(self.created_before().map(|created_before| { + Expr::col((UserSessions::Table, UserSessions::UserSessionId)) + .lt(min_ulid_at(created_before)) + })) .add_option(self.linked_to_upstream_sessions().map(|filter| { Expr::col((UserSessions::Table, UserSessions::UserSessionId)).in_subquery( Query::select() diff --git a/crates/storage-pg/src/user/tests.rs b/crates/storage-pg/src/user/tests.rs index be106c56f..166ee4c10 100644 --- a/crates/storage-pg/src/user/tests.rs +++ b/crates/storage-pg/src/user/tests.rs @@ -888,3 +888,67 @@ async fn test_user_terms(pool: PgPool) { .unwrap(); assert_eq!(res, 2); } + +/// Test the created-at filters on [`BrowserSessionFilter`]. +#[sqlx::test(migrator = "crate::MIGRATOR")] +async fn test_list_browser_sessions_by_created_at(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + + let user = repo + .user() + .add(&mut rng, &clock, "alice".to_owned()) + .await + .unwrap(); + + // Three sessions created one minute apart, with a cutoff captured + // between the second and the third. + let session1 = repo + .browser_session() + .add(&mut rng, &clock, &user, None) + .await + .unwrap(); + clock.advance(Duration::try_minutes(1).unwrap()); + + let session2 = repo + .browser_session() + .add(&mut rng, &clock, &user, None) + .await + .unwrap(); + clock.advance(Duration::try_minutes(1).unwrap()); + + let cutoff = clock.now(); + + clock.advance(Duration::try_minutes(1).unwrap()); + let session3 = repo + .browser_session() + .add(&mut rng, &clock, &user, None) + .await + .unwrap(); + + let pagination = Pagination::first(10); + + // Sessions created before the cutoff + let filter = BrowserSessionFilter::new().with_created_before(cutoff); + let list = repo + .browser_session() + .list(filter, pagination) + .await + .unwrap(); + assert_eq!(list.edges.len(), 2); + assert_eq!(list.edges[0].node, session1); + assert_eq!(list.edges[1].node, session2); + assert_eq!(repo.browser_session().count(filter).await.unwrap(), 2); + + // Sessions created after the cutoff + let filter = BrowserSessionFilter::new().with_created_after(cutoff); + let list = repo + .browser_session() + .list(filter, pagination) + .await + .unwrap(); + assert_eq!(list.edges.len(), 1); + assert_eq!(list.edges[0].node, session3); + assert_eq!(repo.browser_session().count(filter).await.unwrap(), 1); +} diff --git a/crates/storage/src/user/session.rs b/crates/storage/src/user/session.rs index 5e4a108ea..5f7d25afa 100644 --- a/crates/storage/src/user/session.rs +++ b/crates/storage/src/user/session.rs @@ -42,6 +42,8 @@ pub struct BrowserSessionFilter<'a> { state: Option, last_active_before: Option>, last_active_after: Option>, + created_before: Option>, + created_after: Option>, linked_to_upstream_sessions: Option>, } @@ -95,6 +97,36 @@ impl<'a> BrowserSessionFilter<'a> { self.last_active_after } + /// Only return sessions created before the given time + #[must_use] + pub fn with_created_before(mut self, created_before: DateTime) -> Self { + self.created_before = Some(created_before); + self + } + + /// Only return sessions created after the given time + #[must_use] + pub fn with_created_after(mut self, created_after: DateTime) -> Self { + self.created_after = Some(created_after); + self + } + + /// Get the created-before filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn created_before(&self) -> Option> { + self.created_before + } + + /// Get the created-after filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn created_after(&self) -> Option> { + self.created_after + } + /// Only return active browser sessions #[must_use] pub fn active_only(mut self) -> Self { From db8306ed4c521ede964c32dff83a09c0fe89d2d4 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 15:51:18 +0200 Subject: [PATCH 05/11] storage: filter app sessions (compat+oauth2 union) by creation time --- crates/storage-pg/src/app_session.rs | 97 +++++++++++++++++++++++++++- crates/storage/src/app_session.rs | 33 ++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/crates/storage-pg/src/app_session.rs b/crates/storage-pg/src/app_session.rs index 2867534c3..117f1f474 100644 --- a/crates/storage-pg/src/app_session.rs +++ b/crates/storage-pg/src/app_session.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // @@ -259,6 +260,16 @@ fn split_filter( oauth2_filter = oauth2_filter.with_last_active_after(last_active_after); } + if let Some(created_before) = filter.created_before() { + compat_filter = compat_filter.with_created_before(created_before); + oauth2_filter = oauth2_filter.with_created_before(created_before); + } + + if let Some(created_after) = filter.created_after() { + compat_filter = compat_filter.with_created_after(created_after); + oauth2_filter = oauth2_filter.with_created_after(created_after); + } + (compat_filter, oauth2_filter) } @@ -545,7 +556,7 @@ impl AppSessionRepository for PgAppSessionRepository<'_> { #[cfg(test)] mod tests { use chrono::Duration; - use mas_data_model::{Device, clock::MockClock}; + use mas_data_model::{Clock, Device, clock::MockClock}; use mas_storage::{ Pagination, RepositoryAccess, app_session::{AppSession, AppSessionFilter}, @@ -783,4 +794,88 @@ mod tests { let list = repo.app_session().list(filter, pagination).await.unwrap(); assert!(list.edges.is_empty()); } + + /// Test the created-at filters on [`AppSessionFilter`]: they should apply + /// to both the compat and the `OAuth2` branches of the union. + #[sqlx::test(migrator = "crate::MIGRATOR")] + async fn test_list_app_sessions_by_created_at(pool: PgPool) { + let mut rng = ChaChaRng::seed_from_u64(42); + let clock = MockClock::default(); + let mut repo = PgRepository::from_pool(&pool).await.unwrap(); + + let user = repo + .user() + .add(&mut rng, &clock, "alice".to_owned()) + .await + .unwrap(); + + // Create a compat session + let device = Device::generate(&mut rng); + let compat_session = repo + .compat_session() + .add(&mut rng, &clock, &user, device, None, false, None) + .await + .unwrap(); + clock.advance(Duration::try_minutes(1).unwrap()); + + // Capture a cutoff that sits between the compat session and the + // OAuth2 one + let cutoff = clock.now(); + clock.advance(Duration::try_minutes(1).unwrap()); + + // Create an OAuth2 session for the same user + let client = repo + .oauth2_client() + .add( + &mut rng, + &clock, + vec!["https://example.com/redirect".parse().unwrap()], + None, + None, + None, + vec![GrantType::AuthorizationCode], + Some("Test client".to_owned()), + Some("https://example.com/logo.png".parse().unwrap()), + Some("https://example.com/".parse().unwrap()), + Some("https://example.com/policy".parse().unwrap()), + Some("https://example.com/tos".parse().unwrap()), + Some("https://example.com/jwks.json".parse().unwrap()), + None, + None, + None, + None, + None, + Some("https://example.com/login".parse().unwrap()), + ) + .await + .unwrap(); + let scope: Scope = [OPENID].into_iter().collect(); + let oauth_session = repo + .oauth2_session() + .add(&mut rng, &clock, &client, Some(&user), None, scope) + .await + .unwrap(); + + let pagination = Pagination::first(10); + + // Sessions created before the cutoff: only the compat one + let filter = AppSessionFilter::new().with_created_before(cutoff); + let list = repo.app_session().list(filter, pagination).await.unwrap(); + assert_eq!(list.edges.len(), 1); + assert_eq!( + list.edges[0].node, + AppSession::Compat(Box::new(compat_session.clone())) + ); + assert_eq!(repo.app_session().count(filter).await.unwrap(), 1); + + // Sessions created after the cutoff: only the OAuth2 one + let filter = AppSessionFilter::new().with_created_after(cutoff); + let list = repo.app_session().list(filter, pagination).await.unwrap(); + assert_eq!(list.edges.len(), 1); + assert_eq!( + list.edges[0].node, + AppSession::OAuth2(Box::new(oauth_session.clone())) + ); + assert_eq!(repo.app_session().count(filter).await.unwrap(), 1); + } } diff --git a/crates/storage/src/app_session.rs b/crates/storage/src/app_session.rs index 4c0b7703a..d6ca8dce0 100644 --- a/crates/storage/src/app_session.rs +++ b/crates/storage/src/app_session.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // @@ -54,6 +55,8 @@ pub struct AppSessionFilter<'a> { device_id: Option<&'a Device>, last_active_before: Option>, last_active_after: Option>, + created_before: Option>, + created_after: Option>, } impl<'a> AppSessionFilter<'a> { @@ -132,6 +135,36 @@ impl<'a> AppSessionFilter<'a> { self.last_active_after } + /// Only return sessions created before the given time + #[must_use] + pub fn with_created_before(mut self, created_before: DateTime) -> Self { + self.created_before = Some(created_before); + self + } + + /// Only return sessions created after the given time + #[must_use] + pub fn with_created_after(mut self, created_after: DateTime) -> Self { + self.created_after = Some(created_after); + self + } + + /// Get the created-before filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn created_before(&self) -> Option> { + self.created_before + } + + /// Get the created-after filter + /// + /// Returns [`None`] if no filter was set + #[must_use] + pub fn created_after(&self) -> Option> { + self.created_after + } + /// Only return active compatibility sessions #[must_use] pub fn active_only(mut self) -> Self { From 45b0a59a09022613d580e65167965959701cd3a4 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 15:52:41 +0200 Subject: [PATCH 06/11] admin-api: filter OAuth2 session list by creation time --- .../src/admin/v1/oauth2_sessions/list.rs | 126 ++++++++++++++++++ docs/api/spec.json | 44 ++++++ 2 files changed, 170 insertions(+) diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs index 37f6ed378..f0a8faaf3 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2024 The Matrix.org Foundation C.I.C. // @@ -10,6 +11,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; +use chrono::{DateTime, Utc}; use hyper::StatusCode; use mas_axum_utils::record_error; use mas_storage::{Page, oauth2::OAuth2SessionFilter}; @@ -97,6 +99,14 @@ pub struct FilterParams { /// * `finished`: Only retrieve finished sessions #[serde(rename = "filter[status]")] status: Option, + + /// Retrieve sessions created strictly before the given time + #[serde(rename = "filter[created-before]")] + created_before: Option>, + + /// Retrieve sessions created strictly after the given time + #[serde(rename = "filter[created-after]")] + created_after: Option>, } impl std::fmt::Display for FilterParams { @@ -133,6 +143,24 @@ impl std::fmt::Display for FilterParams { sep = '&'; } + if let Some(created_before) = self.created_before { + write!( + f, + "{sep}filter[created-before]={}", + created_before.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + + if let Some(created_after) = self.created_after { + write!( + f, + "{sep}filter[created-after]={}", + created_after.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + let _ = sep; Ok(()) } @@ -304,6 +332,18 @@ pub async fn handler( None => filter, }; + let filter = if let Some(created_before) = params.created_before { + filter.with_created_before(created_before) + } else { + filter + }; + + let filter = if let Some(created_after) = params.created_after { + filter.with_created_after(created_after) + } else { + filter + }; + let response = match include_count { IncludeCount::True => { let page = repo @@ -333,7 +373,13 @@ pub async fn handler( #[cfg(test)] mod tests { + use chrono::Duration; use hyper::{Request, StatusCode}; + use mas_data_model::Clock; + use oauth2_types::{ + requests::GrantType, + scope::{OPENID, Scope}, + }; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; @@ -451,4 +497,84 @@ mod tests { } "#); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_oauth2_session_list_by_created_at(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + let client = repo + .oauth2_client() + .add( + &mut rng, + &state.clock, + vec!["https://example.com/redirect".parse().unwrap()], + None, + None, + None, + vec![GrantType::ClientCredentials], + Some("Test client".to_owned()), + Some("https://example.com/logo.png".parse().unwrap()), + Some("https://example.com/".parse().unwrap()), + Some("https://example.com/policy".parse().unwrap()), + Some("https://example.com/tos".parse().unwrap()), + Some("https://example.com/jwks.json".parse().unwrap()), + None, + None, + None, + None, + None, + Some("https://example.com/login".parse().unwrap()), + ) + .await + .unwrap(); + + // Two extra sessions, one before and one after a cutoff. (There's + // also one pre-existing session from the admin token.) + repo.oauth2_session() + .add_from_client_credentials( + &mut rng, + &state.clock, + &client, + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + state.clock.advance(Duration::minutes(1)); + let cutoff = state.clock.now(); + state.clock.advance(Duration::minutes(1)); + let new_session = repo + .oauth2_session() + .add_from_client_credentials( + &mut rng, + &state.clock, + &client, + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Sessions created after the cutoff: only the second one + let request = Request::get(format!( + "/api/admin/v1/oauth2-sessions?filter[created-after]={}", + cutoff.format("%Y-%m-%dT%H:%M:%SZ") + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["meta"]["count"], 1); + let ids: Vec<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|v| v["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec![new_session.id.to_string()]); + } } diff --git a/docs/api/spec.json b/docs/api/spec.json index bf6a62f3e..8c330e189 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -687,6 +687,34 @@ ] }, "style": "form" + }, + { + "in": "query", + "name": "filter[created-before]", + "description": "Retrieve sessions created strictly before the given time", + "schema": { + "description": "Retrieve sessions created strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[created-after]", + "description": "Retrieve sessions created strictly after the given time", + "schema": { + "description": "Retrieve sessions created strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" } ], "responses": { @@ -5462,6 +5490,22 @@ "type": "null" } ] + }, + "filter[created-before]": { + "description": "Retrieve sessions created strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "filter[created-after]": { + "description": "Retrieve sessions created strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" } } }, From 31797b4b7c8677899a57ae71ab578754ece54c77 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 16:01:51 +0200 Subject: [PATCH 07/11] admin-api: filter OAuth2 session list by last activity --- .../src/admin/v1/oauth2_sessions/list.rs | 112 ++++++++++++++++++ docs/api/spec.json | 44 +++++++ 2 files changed, 156 insertions(+) diff --git a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs index f0a8faaf3..545e17a13 100644 --- a/crates/handlers/src/admin/v1/oauth2_sessions/list.rs +++ b/crates/handlers/src/admin/v1/oauth2_sessions/list.rs @@ -107,6 +107,14 @@ pub struct FilterParams { /// Retrieve sessions created strictly after the given time #[serde(rename = "filter[created-after]")] created_after: Option>, + + /// Retrieve sessions last active strictly before the given time + #[serde(rename = "filter[last-active-before]")] + last_active_before: Option>, + + /// Retrieve sessions last active strictly after the given time + #[serde(rename = "filter[last-active-after]")] + last_active_after: Option>, } impl std::fmt::Display for FilterParams { @@ -161,6 +169,24 @@ impl std::fmt::Display for FilterParams { sep = '&'; } + if let Some(last_active_before) = self.last_active_before { + write!( + f, + "{sep}filter[last-active-before]={}", + last_active_before.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + + if let Some(last_active_after) = self.last_active_after { + write!( + f, + "{sep}filter[last-active-after]={}", + last_active_after.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + let _ = sep; Ok(()) } @@ -344,6 +370,18 @@ pub async fn handler( filter }; + let filter = if let Some(last_active_before) = params.last_active_before { + filter.with_last_active_before(last_active_before) + } else { + filter + }; + + let filter = if let Some(last_active_after) = params.last_active_after { + filter.with_last_active_after(last_active_after) + } else { + filter + }; + let response = match include_count { IncludeCount::True => { let page = repo @@ -498,6 +536,80 @@ mod tests { "#); } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_oauth2_session_list_by_last_active(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + let client = repo + .oauth2_client() + .add( + &mut rng, + &state.clock, + vec!["https://example.com/redirect".parse().unwrap()], + None, + None, + None, + vec![GrantType::ClientCredentials], + Some("Test client".to_owned()), + Some("https://example.com/logo.png".parse().unwrap()), + Some("https://example.com/".parse().unwrap()), + Some("https://example.com/policy".parse().unwrap()), + Some("https://example.com/tos".parse().unwrap()), + Some("https://example.com/jwks.json".parse().unwrap()), + None, + None, + None, + None, + None, + Some("https://example.com/login".parse().unwrap()), + ) + .await + .unwrap(); + + let session = repo + .oauth2_session() + .add_from_client_credentials( + &mut rng, + &state.clock, + &client, + Scope::from_iter([OPENID]), + ) + .await + .unwrap(); + + state.clock.advance(Duration::minutes(5)); + let activity_at = state.clock.now(); + repo.oauth2_session() + .record_batch_activity(vec![(session.id, activity_at, None)]) + .await + .unwrap(); + repo.save().await.unwrap(); + + // Sessions last active after `activity_at - 1m` should include our + // session. + let threshold = activity_at - Duration::minutes(1); + let request = Request::get(format!( + "/api/admin/v1/oauth2-sessions?filter[last-active-after]={}", + threshold.format("%Y-%m-%dT%H:%M:%SZ") + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + let ids: Vec<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|v| v["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&session.id.to_string().as_str())); + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_oauth2_session_list_by_created_at(pool: PgPool) { setup(); diff --git a/docs/api/spec.json b/docs/api/spec.json index 8c330e189..99d0301a4 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -715,6 +715,34 @@ "format": "date-time" }, "style": "form" + }, + { + "in": "query", + "name": "filter[last-active-before]", + "description": "Retrieve sessions last active strictly before the given time", + "schema": { + "description": "Retrieve sessions last active strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[last-active-after]", + "description": "Retrieve sessions last active strictly after the given time", + "schema": { + "description": "Retrieve sessions last active strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" } ], "responses": { @@ -5506,6 +5534,22 @@ "null" ], "format": "date-time" + }, + "filter[last-active-before]": { + "description": "Retrieve sessions last active strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "filter[last-active-after]": { + "description": "Retrieve sessions last active strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" } } }, From c5555b83b8178a00ff65c0541f3fc2f171b5e746 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 16:03:47 +0200 Subject: [PATCH 08/11] admin-api: filter compat session list by creation time --- .../src/admin/v1/compat_sessions/list.rs | 91 ++++++++++++++++++- docs/api/spec.json | 44 +++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/admin/v1/compat_sessions/list.rs b/crates/handlers/src/admin/v1/compat_sessions/list.rs index b407854f6..af4b0f280 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/list.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/list.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -7,6 +8,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; +use chrono::{DateTime, Utc}; use hyper::StatusCode; use mas_axum_utils::record_error; use mas_storage::{Page, compat::CompatSessionFilter}; @@ -64,6 +66,14 @@ pub struct FilterParams { /// * `finished`: Only retrieve finished sessions #[serde(rename = "filter[status]")] status: Option, + + /// Retrieve sessions created strictly before the given time + #[serde(rename = "filter[created-before]")] + created_before: Option>, + + /// Retrieve sessions created strictly after the given time + #[serde(rename = "filter[created-after]")] + created_after: Option>, } impl std::fmt::Display for FilterParams { @@ -85,6 +95,24 @@ impl std::fmt::Display for FilterParams { sep = '&'; } + if let Some(created_before) = self.created_before { + write!( + f, + "{sep}filter[created-before]={}", + created_before.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + + if let Some(created_after) = self.created_after { + write!( + f, + "{sep}filter[created-after]={}", + created_after.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + let _ = sep; Ok(()) } @@ -210,6 +238,18 @@ pub async fn handler( None => filter, }; + let filter = if let Some(created_before) = params.created_before { + filter.with_created_before(created_before) + } else { + filter + }; + + let filter = if let Some(created_after) = params.created_after { + filter.with_created_after(created_after) + } else { + filter + }; + let response = match include_count { IncludeCount::True => { let page = repo @@ -242,7 +282,7 @@ mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; use insta::assert_json_snapshot; - use mas_data_model::Device; + use mas_data_model::{Clock, Device}; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; @@ -650,4 +690,53 @@ mod tests { } "#); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_compat_session_list_by_created_at(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let device1 = Device::generate(&mut rng); + repo.compat_session() + .add(&mut rng, &state.clock, &user, device1, None, false, None) + .await + .unwrap(); + state.clock.advance(Duration::minutes(1)); + let cutoff = state.clock.now(); + state.clock.advance(Duration::minutes(1)); + let device2 = Device::generate(&mut rng); + let after_session = repo + .compat_session() + .add(&mut rng, &state.clock, &user, device2, None, false, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::get(format!( + "/api/admin/v1/compat-sessions?filter[created-after]={}", + cutoff.format("%Y-%m-%dT%H:%M:%SZ") + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["meta"]["count"], 1); + let ids: Vec<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|v| v["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec![after_session.id.to_string()]); + } } diff --git a/docs/api/spec.json b/docs/api/spec.json index 99d0301a4..39811ab44 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -215,6 +215,34 @@ ] }, "style": "form" + }, + { + "in": "query", + "name": "filter[created-before]", + "description": "Retrieve sessions created strictly before the given time", + "schema": { + "description": "Retrieve sessions created strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[created-after]", + "description": "Retrieve sessions created strictly after the given time", + "schema": { + "description": "Retrieve sessions created strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" } ], "responses": { @@ -5101,6 +5129,22 @@ "type": "null" } ] + }, + "filter[created-before]": { + "description": "Retrieve sessions created strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "filter[created-after]": { + "description": "Retrieve sessions created strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" } } }, From 6a92be40d3b5c4f449bcbf35c501dc10c941ce63 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 16:05:45 +0200 Subject: [PATCH 09/11] admin-api: filter compat session list by last activity --- .../src/admin/v1/compat_sessions/list.rs | 86 +++++++++++++++++++ docs/api/spec.json | 44 ++++++++++ 2 files changed, 130 insertions(+) diff --git a/crates/handlers/src/admin/v1/compat_sessions/list.rs b/crates/handlers/src/admin/v1/compat_sessions/list.rs index af4b0f280..b1161effd 100644 --- a/crates/handlers/src/admin/v1/compat_sessions/list.rs +++ b/crates/handlers/src/admin/v1/compat_sessions/list.rs @@ -74,6 +74,14 @@ pub struct FilterParams { /// Retrieve sessions created strictly after the given time #[serde(rename = "filter[created-after]")] created_after: Option>, + + /// Retrieve sessions last active strictly before the given time + #[serde(rename = "filter[last-active-before]")] + last_active_before: Option>, + + /// Retrieve sessions last active strictly after the given time + #[serde(rename = "filter[last-active-after]")] + last_active_after: Option>, } impl std::fmt::Display for FilterParams { @@ -113,6 +121,24 @@ impl std::fmt::Display for FilterParams { sep = '&'; } + if let Some(last_active_before) = self.last_active_before { + write!( + f, + "{sep}filter[last-active-before]={}", + last_active_before.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + + if let Some(last_active_after) = self.last_active_after { + write!( + f, + "{sep}filter[last-active-after]={}", + last_active_after.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + let _ = sep; Ok(()) } @@ -250,6 +276,18 @@ pub async fn handler( filter }; + let filter = if let Some(last_active_before) = params.last_active_before { + filter.with_last_active_before(last_active_before) + } else { + filter + }; + + let filter = if let Some(last_active_after) = params.last_active_after { + filter.with_last_active_after(last_active_after) + } else { + filter + }; + let response = match include_count { IncludeCount::True => { let page = repo @@ -691,6 +729,54 @@ mod tests { "#); } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_compat_session_list_by_last_active(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let device = Device::generate(&mut rng); + let session = repo + .compat_session() + .add(&mut rng, &state.clock, &user, device, None, false, None) + .await + .unwrap(); + + state.clock.advance(Duration::minutes(5)); + let activity_at = state.clock.now(); + repo.compat_session() + .record_batch_activity(vec![(session.id, activity_at, None)]) + .await + .unwrap(); + repo.save().await.unwrap(); + + let threshold = activity_at - Duration::minutes(1); + let request = Request::get(format!( + "/api/admin/v1/compat-sessions?filter[last-active-after]={}", + threshold.format("%Y-%m-%dT%H:%M:%SZ") + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + let ids: Vec<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|v| v["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&session.id.to_string().as_str())); + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_compat_session_list_by_created_at(pool: PgPool) { setup(); diff --git a/docs/api/spec.json b/docs/api/spec.json index 39811ab44..82c29d93f 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -243,6 +243,34 @@ "format": "date-time" }, "style": "form" + }, + { + "in": "query", + "name": "filter[last-active-before]", + "description": "Retrieve sessions last active strictly before the given time", + "schema": { + "description": "Retrieve sessions last active strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[last-active-after]", + "description": "Retrieve sessions last active strictly after the given time", + "schema": { + "description": "Retrieve sessions last active strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" } ], "responses": { @@ -5145,6 +5173,22 @@ "null" ], "format": "date-time" + }, + "filter[last-active-before]": { + "description": "Retrieve sessions last active strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "filter[last-active-after]": { + "description": "Retrieve sessions last active strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" } } }, From 77e84e7f86e0a309298a9c0782e1a1382b5ea85d Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 16:07:22 +0200 Subject: [PATCH 10/11] admin-api: filter browser session list by creation time --- .../src/admin/v1/user_sessions/list.rs | 88 +++++++++++++++++++ docs/api/spec.json | 44 ++++++++++ 2 files changed, 132 insertions(+) diff --git a/crates/handlers/src/admin/v1/user_sessions/list.rs b/crates/handlers/src/admin/v1/user_sessions/list.rs index ad8a05982..516694320 100644 --- a/crates/handlers/src/admin/v1/user_sessions/list.rs +++ b/crates/handlers/src/admin/v1/user_sessions/list.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -7,6 +8,7 @@ use aide::{OperationIo, transform::TransformOperation}; use axum::{Json, response::IntoResponse}; use axum_extra::extract::{Query, QueryRejection}; use axum_macros::FromRequestParts; +use chrono::{DateTime, Utc}; use hyper::StatusCode; use mas_axum_utils::record_error; use mas_storage::{pagination::Page, user::BrowserSessionFilter}; @@ -59,6 +61,14 @@ pub struct FilterParams { /// * `finished`: Only retrieve finished sessions #[serde(rename = "filter[status]")] status: Option, + + /// Retrieve sessions created strictly before the given time + #[serde(rename = "filter[created-before]")] + created_before: Option>, + + /// Retrieve sessions created strictly after the given time + #[serde(rename = "filter[created-after]")] + created_after: Option>, } impl std::fmt::Display for FilterParams { @@ -75,6 +85,24 @@ impl std::fmt::Display for FilterParams { sep = '&'; } + if let Some(created_before) = self.created_before { + write!( + f, + "{sep}filter[created-before]={}", + created_before.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + + if let Some(created_after) = self.created_after { + write!( + f, + "{sep}filter[created-after]={}", + created_after.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + let _ = sep; Ok(()) } @@ -179,6 +207,18 @@ pub async fn handler( None => filter, }; + let filter = if let Some(created_before) = params.created_before { + filter.with_created_before(created_before) + } else { + filter + }; + + let filter = if let Some(created_after) = params.created_after { + filter.with_created_after(created_after) + } else { + filter + }; + let response = match include_count { IncludeCount::True => { let page = repo @@ -211,6 +251,7 @@ mod tests { use chrono::Duration; use hyper::{Request, StatusCode}; use insta::assert_json_snapshot; + use mas_data_model::Clock; use sqlx::PgPool; use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup}; @@ -582,4 +623,51 @@ mod tests { } "#); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_user_session_list_by_created_at(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + repo.browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + state.clock.advance(Duration::minutes(1)); + let cutoff = state.clock.now(); + state.clock.advance(Duration::minutes(1)); + let after_session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::get(format!( + "/api/admin/v1/user-sessions?filter[created-after]={}", + cutoff.format("%Y-%m-%dT%H:%M:%SZ") + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["meta"]["count"], 1); + let ids: Vec<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|v| v["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec![after_session.id.to_string()]); + } } diff --git a/docs/api/spec.json b/docs/api/spec.json index 82c29d93f..cbf45ec39 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -3352,6 +3352,34 @@ ] }, "style": "form" + }, + { + "in": "query", + "name": "filter[created-before]", + "description": "Retrieve sessions created strictly before the given time", + "schema": { + "description": "Retrieve sessions created strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[created-after]", + "description": "Retrieve sessions created strictly after the given time", + "schema": { + "description": "Retrieve sessions created strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" } ], "responses": { @@ -6773,6 +6801,22 @@ "type": "null" } ] + }, + "filter[created-before]": { + "description": "Retrieve sessions created strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "filter[created-after]": { + "description": "Retrieve sessions created strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" } } }, From 4d5e7c79846e5868fab65fed18c8e982e1ed1567 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 28 May 2026 16:09:10 +0200 Subject: [PATCH 11/11] admin-api: filter browser session list by last activity --- .../src/admin/v1/user_sessions/list.rs | 85 +++++++++++++++++++ docs/api/spec.json | 44 ++++++++++ 2 files changed, 129 insertions(+) diff --git a/crates/handlers/src/admin/v1/user_sessions/list.rs b/crates/handlers/src/admin/v1/user_sessions/list.rs index 516694320..a594070b4 100644 --- a/crates/handlers/src/admin/v1/user_sessions/list.rs +++ b/crates/handlers/src/admin/v1/user_sessions/list.rs @@ -69,6 +69,14 @@ pub struct FilterParams { /// Retrieve sessions created strictly after the given time #[serde(rename = "filter[created-after]")] created_after: Option>, + + /// Retrieve sessions last active strictly before the given time + #[serde(rename = "filter[last-active-before]")] + last_active_before: Option>, + + /// Retrieve sessions last active strictly after the given time + #[serde(rename = "filter[last-active-after]")] + last_active_after: Option>, } impl std::fmt::Display for FilterParams { @@ -103,6 +111,24 @@ impl std::fmt::Display for FilterParams { sep = '&'; } + if let Some(last_active_before) = self.last_active_before { + write!( + f, + "{sep}filter[last-active-before]={}", + last_active_before.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + + if let Some(last_active_after) = self.last_active_after { + write!( + f, + "{sep}filter[last-active-after]={}", + last_active_after.format("%Y-%m-%dT%H:%M:%SZ") + )?; + sep = '&'; + } + let _ = sep; Ok(()) } @@ -219,6 +245,18 @@ pub async fn handler( filter }; + let filter = if let Some(last_active_before) = params.last_active_before { + filter.with_last_active_before(last_active_before) + } else { + filter + }; + + let filter = if let Some(last_active_after) = params.last_active_after { + filter.with_last_active_after(last_active_after) + } else { + filter + }; + let response = match include_count { IncludeCount::True => { let page = repo @@ -624,6 +662,53 @@ mod tests { "#); } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_user_session_list_by_last_active(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + let user = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + + let session = repo + .browser_session() + .add(&mut rng, &state.clock, &user, None) + .await + .unwrap(); + + state.clock.advance(Duration::minutes(5)); + let activity_at = state.clock.now(); + repo.browser_session() + .record_batch_activity(vec![(session.id, activity_at, None)]) + .await + .unwrap(); + repo.save().await.unwrap(); + + let threshold = activity_at - Duration::minutes(1); + let request = Request::get(format!( + "/api/admin/v1/user-sessions?filter[last-active-after]={}", + threshold.format("%Y-%m-%dT%H:%M:%SZ") + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + let ids: Vec<&str> = body["data"] + .as_array() + .unwrap() + .iter() + .map(|v| v["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&session.id.to_string().as_str())); + } + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_user_session_list_by_created_at(pool: PgPool) { setup(); diff --git a/docs/api/spec.json b/docs/api/spec.json index cbf45ec39..49d05aec9 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -3380,6 +3380,34 @@ "format": "date-time" }, "style": "form" + }, + { + "in": "query", + "name": "filter[last-active-before]", + "description": "Retrieve sessions last active strictly before the given time", + "schema": { + "description": "Retrieve sessions last active strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[last-active-after]", + "description": "Retrieve sessions last active strictly after the given time", + "schema": { + "description": "Retrieve sessions last active strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "style": "form" } ], "responses": { @@ -6817,6 +6845,22 @@ "null" ], "format": "date-time" + }, + "filter[last-active-before]": { + "description": "Retrieve sessions last active strictly before the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "filter[last-active-after]": { + "description": "Retrieve sessions last active strictly after the given time", + "type": [ + "string", + "null" + ], + "format": "date-time" } } },