mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-23 08:43:33 +00:00
Admin API: allow filtering of sessions by creation and last-activity time (#5709)
This commit is contained in:
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()]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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())
|
||||
}))
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user