From f70abb914e180d6d473f22062c0ff7233ffb7770 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 16 Jul 2026 18:04:16 +0200 Subject: [PATCH 1/3] Add a human_account_name filter to the upstream OAuth link repository Fuzzy (case-insensitive substring) matching on the link's human_account_name, following the OAuth 2.0 client name filter precedent. --- crates/storage-pg/src/upstream_oauth2/link.rs | 11 ++++- crates/storage-pg/src/upstream_oauth2/mod.rs | 42 ++++++++++++++++++- crates/storage/src/upstream_oauth2/link.rs | 17 ++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/crates/storage-pg/src/upstream_oauth2/link.rs b/crates/storage-pg/src/upstream_oauth2/link.rs index 8136a24ee..d377901fc 100644 --- a/crates/storage-pg/src/upstream_oauth2/link.rs +++ b/crates/storage-pg/src/upstream_oauth2/link.rs @@ -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}%")) + })) } } diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index 7c912daf0..cd16ee629 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -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() diff --git a/crates/storage/src/upstream_oauth2/link.rs b/crates/storage/src/upstream_oauth2/link.rs index 27e3ca677..1053e6dd9 100644 --- a/crates/storage/src/upstream_oauth2/link.rs +++ b/crates/storage/src/upstream_oauth2/link.rs @@ -20,6 +20,7 @@ pub struct UpstreamOAuthLinkFilter<'a> { provider: Option<&'a UpstreamOAuthProvider>, provider_enabled: Option, 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 From 85c902e000fadc15736eb25b004d80fbf44dec61 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 16 Jul 2026 18:04:27 +0200 Subject: [PATCH 2/3] Add a trigram index on upstream_oauth_links.human_account_name Backs the ILIKE '%search%' fuzzy filter on the human account name. --- ...pstream_oauth_links_human_account_name_trgm_idx.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 crates/storage-pg/migrations/20260716100001_upstream_oauth_links_human_account_name_trgm_idx.sql diff --git a/crates/storage-pg/migrations/20260716100001_upstream_oauth_links_human_account_name_trgm_idx.sql b/crates/storage-pg/migrations/20260716100001_upstream_oauth_links_human_account_name_trgm_idx.sql new file mode 100644 index 000000000..9a6e38d44 --- /dev/null +++ b/crates/storage-pg/migrations/20260716100001_upstream_oauth_links_human_account_name_trgm_idx.sql @@ -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); From 406d678a6fbde5a156d4cbbce2025502b5184e5b Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 16 Jul 2026 18:08:25 +0200 Subject: [PATCH 3/3] Add a filter[human-account-name] param to the upstream OAuth links admin API Fuzzy (case-insensitive substring) search on the link's human account name. --- .../src/admin/v1/upstream_oauth_links/list.rs | 79 +++++++++++++++++++ docs/api/spec.json | 17 ++++ 2 files changed, 96 insertions(+) diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs index c233a9977..a5ee985df 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/list.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial @@ -42,6 +43,11 @@ pub struct FilterParams { /// Retrieve the items with the given subject #[serde(rename = "filter[subject]")] subject: Option, + + /// Retrieve the items with a human account name matching the given + /// substring (case-insensitive) + #[serde(rename = "filter[human-account-name]")] + human_account_name: Option, } 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) = ¶ms.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) diff --git a/docs/api/spec.json b/docs/api/spec.json index 992bdf0ca..72c4bf0e2 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -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" + ] } } },