admin-api: allow filtering OAuth2 sessions by multiple clients

Make `filter[client]` on `GET /api/admin/v1/oauth2-sessions` repeatable
so admin tooling can fetch sessions belonging to a set of clients in
one request. The field on `FilterParams` changes from `Option<Ulid>`
to `Vec<Ulid>`; the struct was already extracted with
`axum_extra::extract::Query` so the repeated values are not silently
dropped.

Each client ULID is validated to exist (mirroring the previous
single-client `404` behaviour) before being passed to the new
`OAuth2SessionFilter::for_clients` storage filter. The `Display`
impl used to reconstruct cursor links now emits one
`filter[client]=…` segment per client so pagination preserves the
filter. The OpenAPI schema is regenerated via `misc/update.sh` and
now describes the parameter as an array.
This commit is contained in:
Quentin Gliech
2026-06-01 17:30:14 +02:00
parent 5cb9ca1043
commit ce68d63adb
2 changed files with 142 additions and 34 deletions
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
@@ -70,10 +71,13 @@ pub struct FilterParams {
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
user: Option<Ulid>,
/// Retrieve the items for the given client
#[serde(rename = "filter[client]")]
#[schemars(with = "Option<crate::admin::schema::Ulid>")]
client: Option<Ulid>,
/// Retrieve the items for the given client(s)
///
/// This parameter may be repeated to filter on multiple clients at
/// once (sessions matching any of the given clients are returned).
#[serde(default, rename = "filter[client]")]
#[schemars(with = "Vec<crate::admin::schema::Ulid>")]
client: Vec<Ulid>,
/// Retrieve the items only for a specific client kind
#[serde(rename = "filter[client-kind]")]
@@ -108,7 +112,7 @@ impl std::fmt::Display for FilterParams {
sep = '&';
}
if let Some(client) = self.client {
for client in &self.client {
write!(f, "{sep}filter[client]={client}")?;
sep = '&';
}
@@ -183,7 +187,8 @@ pub fn doc(operation: TransformOperation) -> TransformOperation {
.summary("List OAuth 2.0 sessions")
.description("Retrieve a list of OAuth 2.0 sessions.
Note that by default, all sessions, including finished ones are returned, with the oldest first.
Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.")
Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.
The `filter[client]` parameter may be repeated to filter on multiple clients at once.")
.tag("oauth2-session")
.response_with::<200, Json<PaginatedResponse<OAuth2Session>>, _>(|t| {
let sessions = OAuth2Session::samples();
@@ -246,21 +251,21 @@ pub async fn handler(
None => filter,
};
let client = if let Some(client_id) = params.client {
let mut clients = Vec::with_capacity(params.client.len());
for client_id in params.client {
let client = repo
.oauth2_client()
.lookup(client_id)
.await?
.ok_or(RouteError::ClientNotFound(client_id))?;
clients.push(client);
}
let client_refs: Vec<&_> = clients.iter().collect();
Some(client)
let filter = if client_refs.is_empty() {
filter
} else {
None
};
let filter = match &client {
Some(client) => filter.for_client(client),
None => filter,
filter.for_clients(&client_refs)
};
let filter = match params.client_kind {
@@ -334,6 +339,7 @@ pub async fn handler(
#[cfg(test)]
mod tests {
use hyper::{Request, StatusCode};
use oauth2_types::{requests::GrantType, scope::Scope};
use sqlx::PgPool;
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
@@ -451,4 +457,112 @@ mod tests {
}
"#);
}
/// 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
/// client).
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_oauth2_session_list_multiple_clients(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();
// Provision two clients and a session for each, both using the
// client_credentials flow so that they don't depend on a user.
let mut repo = state.repository().await.unwrap();
let client_a = repo
.oauth2_client()
.add(
&mut rng,
&state.clock,
vec!["https://a.example.com/redirect".parse().unwrap()],
None,
None,
None,
vec![GrantType::ClientCredentials],
Some("client a".to_owned()),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await
.unwrap();
let client_b = repo
.oauth2_client()
.add(
&mut rng,
&state.clock,
vec!["https://b.example.com/redirect".parse().unwrap()],
None,
None,
None,
vec![GrantType::ClientCredentials],
Some("client b".to_owned()),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await
.unwrap();
let scope: Scope = "urn:mas:admin".parse().unwrap();
let session_a = repo
.oauth2_session()
.add_from_client_credentials(&mut rng, &state.clock, &client_a, scope.clone())
.await
.unwrap();
let session_b = repo
.oauth2_session()
.add_from_client_credentials(&mut rng, &state.clock, &client_b, scope.clone())
.await
.unwrap();
repo.save().await.unwrap();
// Filter on both new clients. The admin session (a third client) must
// not appear in the result.
let url = format!(
"/api/admin/v1/oauth2-sessions?filter[client]={}&filter[client]={}",
client_a.id, client_b.id,
);
let request = Request::get(&url).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"], 2);
let ids: Vec<&str> = body["data"]
.as_array()
.unwrap()
.iter()
.map(|v| v["id"].as_str().unwrap())
.collect();
let session_a_id = session_a.id.to_string();
let session_b_id = session_b.id.to_string();
assert!(ids.contains(&session_a_id.as_str()));
assert!(ids.contains(&session_b_id.as_str()));
assert_eq!(ids.len(), 2);
// The self/first/last links should preserve both filter[client] segments
let self_link = body["links"]["self"].as_str().unwrap();
assert!(self_link.contains(&format!("filter[client]={}", client_a.id)));
assert!(self_link.contains(&format!("filter[client]={}", client_b.id)));
}
}
+14 -20
View File
@@ -505,7 +505,7 @@
"oauth2-session"
],
"summary": "List OAuth 2.0 sessions",
"description": "Retrieve a list of OAuth 2.0 sessions.\nNote that by default, all sessions, including finished ones are returned, with the oldest first.\nUse the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.",
"description": "Retrieve a list of OAuth 2.0 sessions.\nNote that by default, all sessions, including finished ones are returned, with the oldest first.\nUse the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.\nThe `filter[client]` parameter may be repeated to filter on multiple clients at once.",
"operationId": "listOAuth2Sessions",
"parameters": [
{
@@ -609,17 +609,14 @@
{
"in": "query",
"name": "filter[client]",
"description": "Retrieve the items for the given client",
"description": "Retrieve the items for the given client(s)\n\n This parameter may be repeated to filter on multiple clients at\n once (sessions matching any of the given clients are returned).",
"schema": {
"description": "Retrieve the items for the given client",
"anyOf": [
{
"$ref": "#/components/schemas/ULID"
},
{
"type": "null"
}
]
"description": "Retrieve the items for the given client(s)\n\n This parameter may be repeated to filter on multiple clients at\n once (sessions matching any of the given clients are returned).",
"type": "array",
"items": {
"$ref": "#/components/schemas/ULID"
},
"default": []
},
"style": "form"
},
@@ -5412,15 +5409,12 @@
]
},
"filter[client]": {
"description": "Retrieve the items for the given client",
"anyOf": [
{
"$ref": "#/components/schemas/ULID"
},
{
"type": "null"
}
]
"description": "Retrieve the items for the given client(s)\n\n This parameter may be repeated to filter on multiple clients at\n once (sessions matching any of the given clients are returned).",
"type": "array",
"items": {
"$ref": "#/components/schemas/ULID"
},
"default": []
},
"filter[client-kind]": {
"description": "Retrieve the items only for a specific client kind",