Refactor: introduce UserQueryBuilder for user advanced search queries
What does this MR do and why?
Migrates UserClassProxy#elastic_search to use the Search::Elastic::QueryBuilder
pattern, removing tightly-coupled inline query construction in favour of dedicated
builder and shared Queries/Filters primitives. This is part of the ongoing
effort to decouple Elasticsearch query building from ApplicationClassProxy so
that new data models (e.g. WorkItem) can be added or modified without inheriting
complex, shared proxy logic.
New shared primitives (reusable beyond users):
Search::Elastic::Queries.by_fuzzy_text— per-field fuzzymatchqueries usingBoolExpr+add_query_conditions, socount_onlyandkeyword_match_clausework automaticallySearch::Elastic::Filters.by_forbidden_states— filters out users in a forbidden state; usescan_admin_all_resources?consistent with other filters (replaces the oldoptions[:admin]flag check)
New builder:
Search::Elastic::UserQueryBuilder— declarative pipeline usingQUERY_COMPONENTS, no raw query hashes inline- Uses
ADVANCED_QUERY_SYNTAX_REGEX(the canonical regex) instead of the narrower ad-hoc regex previously inUserClassProxy - Field visibility (
emailfor admins only) now driven bycan_admin_all_resources?on the user object, consistent with the rest of the codebase
QueryBuilder base class improvements:
QUERY_COMPONENTS+each_componentpipeline generalised intoQueryBuilderbase class so all builders share a single declarative execution model- New hooks:
build_initial_query_hash(override to provide the starting query, e.g. fuzzy or full-text) andprepare_options(override to set dynamic options before the pipeline runs) by_fuzzy_text,by_multi_match_query, andby_simple_query_stringinSearch::Elastic::Queriesnormalised to readfieldsfromoptions[:fields]rather than accepting it as a separate argument;by_multi_match_queryandby_simple_query_stringmade private since they are internal toby_full_textVulnerabilityQueryBuildermigrated to inherit the base class pipeline, removing its duplicatebuildandeach_component
Rollback safety:
- Gated behind
search_users_use_query_builder(gitlab_com_derisk) feature flag withcurrent_useras the actor - Legacy code path preserved in
UserClassProxy#legacy_query_hashuntil the flag is removed in a follow-up
References
- Closes #462683
Screenshots or screen recordings
N/A — backend refactor with no UI changes.
| Before | After |
|---|---|
| N/A | N/A |
How to set up and validate locally
Prerequisites
- Enable Elasticsearch in GDK and ensure it's running
- Enable Advanced Search: Admin → Settings → Advanced Search → Enable
- Enable the feature flag (this MR's new code path):
To test the legacy path:
Feature.enable(:search_users_use_query_builder)Feature.disable(:search_users_use_query_builder) - Create test users (suggested names to cover query types):
- bob.smith / display name "Bob Smith" — plain text search target
- bo_wildcard / display name "Bo Wildcard" — for wildcard query (bo*)
- A user with a public email set (Profile → Public email)
- An admin account
Setup: Membership Requirements
User search results are scoped by namespace membership:
| Search level | Who appears in results |
|---|---|
| Autocomplete | Users who share a group or project with the searching user |
| Global | Any users |
| Group | Members of that group (direct or inherited) |
| Project | Members of that project |
So for test users to appear in results:
- Autocomplete: Add both the searching user and the target user to at least one common group or project
- Global search: No prerequisite setup
- Group search: Add the target user as a member of the group being searched
- Project search: Add the target user as a member of the project being searched
Admin exception: Admins can see all users regardless of membership (via can_admin_all_resources?)
Test Cases
1. Autocomplete (/autocomplete/users?search=...)
Autocomplete does not go through UserQueryBuilder — it uses a separate database-backed path. It's not the focus of this MR, but worth verifying it still works:
- Navigate to any issue/MR and type
@bobin the assignee field or a comment — target user should appear
2. Global Search (/search?scope=users&search=...)
Navigate to the global search bar and search with scope set to Users. Query Expected behaviour bob Fuzzy match — returns users with bob in name/username/public_email (with typo tolerance) bo* Simple query string — returns users matching the wildcard, no fuzzy tolerance bob -smith Simple query string with exclusion — returns users with bob but not smith Admin vs non-admin:
- As non-admin: searching by private email (e.g. user@example.com) should return no results
- As admin: searching by private email should return the matching user
- As non-admin: users in forbidden state should be filtered out
- As admin: forbidden-state users appear in results
3. Group Search (/groups//-/search?scope=users&search=...)
Navigate to a group → Search → filter by Users.
- Only members of the group should appear
- Add bob.smith as a Guest member of the group — they should appear in results
- A user who is not a member of the group should not appear
- Same query type variants as above (plain text, bo*, bob -smith)
4. Project Search (/namespace/project/-/search?scope=users&search=...)
Navigate to a project → Search → filter by Users.
- Only members of the project (direct or inherited via group) should appear
- Add bob.smith as a Reporter on the project — they should appear
- A user who is only a group member (not the project's group) should not appear
- Same query type variants as above
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.