Admin API: allow filtering of sessions by creation and last-activity time (#5709)

This commit is contained in:
Quentin Gliech authored and GitHub committed 2026-06-17 17:12:01 +02:00
commit 61cffe2124
17 files changed
+1421 -3

No files matched your search

@@ -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,22 @@ pub struct FilterParams {
/// * `finished`: Only retrieve finished sessions
#[serde(rename = "filter[status]")]
status: Option<CompatSessionStatus>,
/// Retrieve sessions created strictly before the given time
#[serde(rename = "filter[created-before]")]
created_before: Option<DateTime<Utc>>,
/// Retrieve sessions created strictly after the given time
#[serde(rename = "filter[created-after]")]
created_after: Option<DateTime<Utc>>,
/// Retrieve sessions last active strictly before the given time
#[serde(rename = "filter[last-active-before]")]
last_active_before: Option<DateTime<Utc>>,
/// Retrieve sessions last active strictly after the given time
#[serde(rename = "filter[last-active-after]")]
last_active_after: Option<DateTime<Utc>>,
}
impl std::fmt::Display for FilterParams {
@@ -85,6 +103,42 @@ 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 = '&';
}
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(())
}
@@ -210,6 +264,30 @@ 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 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
@@ -242,7 +320,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 +728,101 @@ 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();
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()]);
}
}
@@ -11,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};
@@ -101,6 +102,22 @@ pub struct FilterParams {
/// * `finished`: Only retrieve finished sessions
#[serde(rename = "filter[status]")]
status: Option<OAuth2SessionStatus>,
/// Retrieve sessions created strictly before the given time
#[serde(rename = "filter[created-before]")]
created_before: Option<DateTime<Utc>>,
/// Retrieve sessions created strictly after the given time
#[serde(rename = "filter[created-after]")]
created_after: Option<DateTime<Utc>>,
/// Retrieve sessions last active strictly before the given time
#[serde(rename = "filter[last-active-before]")]
last_active_before: Option<DateTime<Utc>>,
/// Retrieve sessions last active strictly after the given time
#[serde(rename = "filter[last-active-after]")]
last_active_after: Option<DateTime<Utc>>,
}
impl std::fmt::Display for FilterParams {
@@ -137,6 +154,42 @@ 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 = '&';
}
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(())
}
@@ -309,6 +362,30 @@ 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 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
@@ -338,8 +415,13 @@ pub async fn handler(
#[cfg(test)]
mod tests {
use chrono::Duration;
use hyper::{Request, StatusCode};
use oauth2_types::{requests::GrantType, scope::Scope};
use mas_data_model::Clock;
use oauth2_types::{
requests::GrantType,
scope::{OPENID, Scope},
};
use sqlx::PgPool;
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
@@ -458,6 +540,160 @@ 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();
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()]);
}
/// Provisions two extra clients and a session for each, then verifies
/// that listing with two `filter[client]` query parameters returns both
/// sessions (and excludes the admin session for the third, unrelated
@@ -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,22 @@ pub struct FilterParams {
/// * `finished`: Only retrieve finished sessions
#[serde(rename = "filter[status]")]
status: Option<UserSessionStatus>,
/// Retrieve sessions created strictly before the given time
#[serde(rename = "filter[created-before]")]
created_before: Option<DateTime<Utc>>,
/// Retrieve sessions created strictly after the given time
#[serde(rename = "filter[created-after]")]
created_after: Option<DateTime<Utc>>,
/// Retrieve sessions last active strictly before the given time
#[serde(rename = "filter[last-active-before]")]
last_active_before: Option<DateTime<Utc>>,
/// Retrieve sessions last active strictly after the given time
#[serde(rename = "filter[last-active-after]")]
last_active_after: Option<DateTime<Utc>>,
}
impl std::fmt::Display for FilterParams {
@@ -75,6 +93,42 @@ 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 = '&';
}
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(())
}
@@ -179,6 +233,30 @@ 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 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
@@ -211,6 +289,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 +661,98 @@ 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();
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()]);
}
}
+96 -1
View File
@@ -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);
}
}
+68
View File
@@ -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";
+12
View File
@@ -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())
}))
+2
View File
@@ -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::{
+96
View File
@@ -727,6 +727,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 multi-client filter on [`OAuth2SessionFilter`].
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_list_sessions_for_clients(pool: PgPool) {
+12
View File
@@ -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
@@ -201,6 +202,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))
}))
}
}
+80
View File
@@ -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<Utc>) -> 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<Utc>) -> 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));
}
}
+12
View File
@@ -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()
+64
View File
@@ -889,3 +889,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);
}
+33
View File
@@ -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<DateTime<Utc>>,
last_active_after: Option<DateTime<Utc>>,
created_before: Option<DateTime<Utc>>,
created_after: Option<DateTime<Utc>>,
}
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<Utc>) -> 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<Utc>) -> 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<DateTime<Utc>> {
self.created_before
}
/// Get the created-after filter
///
/// Returns [`None`] if no filter was set
#[must_use]
pub fn created_after(&self) -> Option<DateTime<Utc>> {
self.created_after
}
/// Only return active compatibility sessions
#[must_use]
pub fn active_only(mut self) -> Self {
+32
View File
@@ -66,6 +66,8 @@ pub struct CompatSessionFilter<'a> {
device: Option<&'a Device>,
last_active_before: Option<DateTime<Utc>>,
last_active_after: Option<DateTime<Utc>>,
created_before: Option<DateTime<Utc>>,
created_after: Option<DateTime<Utc>>,
}
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<Utc>) -> 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<Utc>) -> 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<DateTime<Utc>> {
self.created_before
}
/// Get the created-after filter
///
/// Returns [`None`] if no filter was set
#[must_use]
pub fn created_after(&self) -> Option<DateTime<Utc>> {
self.created_after
}
/// Only return active compatibility sessions
#[must_use]
pub fn active_only(mut self) -> Self {
+32
View File
@@ -59,6 +59,8 @@ pub struct OAuth2SessionFilter<'a> {
scope: Option<&'a Scope>,
last_active_before: Option<DateTime<Utc>>,
last_active_after: Option<DateTime<Utc>>,
created_before: Option<DateTime<Utc>>,
created_after: Option<DateTime<Utc>>,
}
impl<'a> OAuth2SessionFilter<'a> {
@@ -224,6 +226,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<Utc>) -> 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<Utc>) -> 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<DateTime<Utc>> {
self.created_before
}
/// Get the created-after filter
///
/// Returns [`None`] if no filter was set
#[must_use]
pub fn created_after(&self) -> Option<DateTime<Utc>> {
self.created_after
}
/// Only return active sessions
#[must_use]
pub fn active_only(mut self) -> Self {
+32
View File
@@ -42,6 +42,8 @@ pub struct BrowserSessionFilter<'a> {
state: Option<BrowserSessionState>,
last_active_before: Option<DateTime<Utc>>,
last_active_after: Option<DateTime<Utc>>,
created_before: Option<DateTime<Utc>>,
created_after: Option<DateTime<Utc>>,
linked_to_upstream_sessions: Option<UpstreamOAuthSessionFilter<'a>>,
}
@@ -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<Utc>) -> 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<Utc>) -> 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<DateTime<Utc>> {
self.created_before
}
/// Get the created-after filter
///
/// Returns [`None`] if no filter was set
#[must_use]
pub fn created_after(&self) -> Option<DateTime<Utc>> {
self.created_after
}
/// Only return active browser sessions
#[must_use]
pub fn active_only(mut self) -> Self {
+264
View File
@@ -216,6 +216,62 @@
]
},
"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"
},
{
"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": {
@@ -685,6 +741,62 @@
]
},
"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"
},
{
"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": {
@@ -3238,6 +3350,62 @@
]
},
"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"
},
{
"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": {
@@ -5052,6 +5220,38 @@
"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"
},
"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"
}
}
},
@@ -5466,6 +5666,38 @@
"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"
},
"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"
}
}
},
@@ -6601,6 +6833,38 @@
"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"
},
"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"
}
}
},