Admin API: allow searching in upstream OAuth links on 'human account name' (#5869)

This commit is contained in:
Quentin Gliech authored and GitHub committed 2026-07-28 13:32:21 +02:00
commit 40812c0da0
6 files changed
+174 -2

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
@@ -42,6 +43,11 @@ pub struct FilterParams {
/// Retrieve the items with the given subject
#[serde(rename = "filter[subject]")]
subject: Option<String>,
/// Retrieve the items with a human account name matching the given
/// substring (case-insensitive)
#[serde(rename = "filter[human-account-name]")]
human_account_name: Option<String>,
}
impl std::fmt::Display for FilterParams {
@@ -63,6 +69,11 @@ impl std::fmt::Display for FilterParams {
sep = '&';
}
if let Some(human_account_name) = &self.human_account_name {
write!(f, "{sep}filter[human-account-name]={human_account_name}")?;
sep = '&';
}
let _ = sep;
Ok(())
}
@@ -187,6 +198,12 @@ pub async fn handler(
filter
};
let filter = if let Some(name) = &params.human_account_name {
filter.matching_human_account_name(name)
} else {
filter
};
let response = match include_count {
IncludeCount::True => {
let page = repo
@@ -561,6 +578,68 @@ mod tests {
}
"#);
// Filter by human account name (case-insensitive substring)
let request =
Request::get("/api/admin/v1/upstream-oauth-links?filter[human-account-name]=ALICE%40")
.bearer(&token)
.empty();
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_json_snapshot!(body, @r#"
{
"meta": {
"count": 2
},
"data": [
{
"type": "upstream-oauth-link",
"id": "01FSHN9AG0AQZQP8DX40GD59PW",
"attributes": {
"created_at": "2022-01-16T14:40:00Z",
"provider_id": "01FSHN9AG09NMZYX8MFYH578R9",
"subject": "subject1",
"user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
"human_account_name": "alice@acme"
},
"links": {
"self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0AQZQP8DX40GD59PW"
},
"meta": {
"page": {
"cursor": "01FSHN9AG0AQZQP8DX40GD59PW"
}
}
},
{
"type": "upstream-oauth-link",
"id": "01FSHN9AG0QHEHKX2JNQ2A2D07",
"attributes": {
"created_at": "2022-01-16T14:40:00Z",
"provider_id": "01FSHN9AG0KEPHYQQXW9XPTX6Z",
"subject": "subject2",
"user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
"human_account_name": "alice@example"
},
"links": {
"self": "/api/admin/v1/upstream-oauth-links/01FSHN9AG0QHEHKX2JNQ2A2D07"
},
"meta": {
"page": {
"cursor": "01FSHN9AG0QHEHKX2JNQ2A2D07"
}
}
}
],
"links": {
"self": "/api/admin/v1/upstream-oauth-links?filter[human-account-name]=ALICE@&page[first]=10",
"first": "/api/admin/v1/upstream-oauth-links?filter[human-account-name]=ALICE@&page[first]=10",
"last": "/api/admin/v1/upstream-oauth-links?filter[human-account-name]=ALICE@&page[last]=10"
}
}
"#);
// Test count=false
let request = Request::get("/api/admin/v1/upstream-oauth-links?count=false")
.bearer(&token)
@@ -0,0 +1,10 @@
-- no-transaction
-- 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.
-- This adds an index on the human_account_name field for ILIKE '%search%'
-- operations, enabling fuzzy searches of upstream OAuth link account names
CREATE INDEX CONCURRENTLY IF NOT EXISTS upstream_oauth_links_human_account_name_trgm_idx
ON upstream_oauth_links USING gin(human_account_name gin_trgm_ops);
+10 -1
View File
@@ -15,7 +15,9 @@ use mas_storage::{
};
use opentelemetry_semantic_conventions::trace::DB_QUERY_TEXT;
use rand::RngCore;
use sea_query::{Expr, ExprTrait, PostgresQueryBuilder, Query, enum_def};
use sea_query::{
Expr, ExprTrait, PostgresQueryBuilder, Query, enum_def, extension::postgres::PgExpr as _,
};
use sea_query_sqlx::SqlxBinder;
use sqlx::PgConnection;
use tracing::Instrument;
@@ -114,6 +116,13 @@ impl Filter for UpstreamOAuthLinkFilter<'_> {
.add_option(self.subject().map(|subject| {
Expr::col((UpstreamOAuthLinks::Table, UpstreamOAuthLinks::Subject)).eq(subject)
}))
.add_option(self.human_account_name().map(|human_account_name| {
Expr::col((
UpstreamOAuthLinks::Table,
UpstreamOAuthLinks::HumanAccountName,
))
.ilike(format!("%{human_account_name}%"))
}))
}
}
+41 -1
View File
@@ -131,7 +131,13 @@ mod tests {
// Create a link
let link = repo
.upstream_oauth_link()
.add(&mut rng, &clock, &provider, "a-subject".to_owned(), None)
.add(
&mut rng,
&clock,
&provider,
"a-subject".to_owned(),
Some("alice@example.com".to_owned()),
)
.await
.unwrap();
@@ -220,6 +226,40 @@ mod tests {
assert_eq!(repo.upstream_oauth_link().count(filter).await.unwrap(), 1);
// Filtering on a case-insensitive partial human_account_name should match
let matching_filter = UpstreamOAuthLinkFilter::new().matching_human_account_name("LICE");
let links = repo
.upstream_oauth_link()
.list(matching_filter, Pagination::first(10))
.await
.unwrap();
assert_eq!(links.edges.len(), 1);
assert_eq!(links.edges[0].node.id, link.id);
assert_eq!(
repo.upstream_oauth_link()
.count(matching_filter)
.await
.unwrap(),
1
);
// A non-matching human_account_name should return nothing
let non_matching_filter =
UpstreamOAuthLinkFilter::new().matching_human_account_name("nope");
let links = repo
.upstream_oauth_link()
.list(non_matching_filter, Pagination::first(10))
.await
.unwrap();
assert_eq!(links.edges.len(), 0);
assert_eq!(
repo.upstream_oauth_link()
.count(non_matching_filter)
.await
.unwrap(),
0
);
// There should be exactly one enabled provider
assert_eq!(
repo.upstream_oauth_provider()
@@ -20,6 +20,7 @@ pub struct UpstreamOAuthLinkFilter<'a> {
provider: Option<&'a UpstreamOAuthProvider>,
provider_enabled: Option<bool>,
subject: Option<&'a str>,
human_account_name: Option<&'a str>,
}
impl<'a> UpstreamOAuthLinkFilter<'a> {
@@ -91,6 +92,22 @@ impl<'a> UpstreamOAuthLinkFilter<'a> {
pub const fn subject(&self) -> Option<&str> {
self.subject
}
/// Only return links whose `human_account_name` matches the given substring
/// (case-insensitive)
#[must_use]
pub fn matching_human_account_name(mut self, human_account_name: &'a str) -> Self {
self.human_account_name = Some(human_account_name);
self
}
/// Get the human account name filter
///
/// Returns [`None`] if no filter was set
#[must_use]
pub fn human_account_name(&self) -> Option<&'a str> {
self.human_account_name
}
}
/// An [`UpstreamOAuthLinkRepository`] helps interacting with
+17
View File
@@ -4325,6 +4325,16 @@
"type": "string"
},
"style": "form"
},
{
"in": "query",
"name": "filter[human-account-name]",
"description": "Retrieve the items with a human account name matching the given\nsubstring (case-insensitive)",
"schema": {
"description": "Retrieve the items with a human account name matching the given\nsubstring (case-insensitive)",
"type": "string"
},
"style": "form"
}
],
"responses": {
@@ -7452,6 +7462,13 @@
"string",
"null"
]
},
"filter[human-account-name]": {
"description": "Retrieve the items with a human account name matching the given\nsubstring (case-insensitive)",
"type": [
"string",
"null"
]
}
}
},