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 fuzzy match queries using BoolExpr + add_query_conditions, so count_only and keyword_match_clause work automatically
  • Search::Elastic::Filters.by_forbidden_states — filters out users in a forbidden state; uses can_admin_all_resources? consistent with other filters (replaces the old options[:admin] flag check)

New builder:

  • Search::Elastic::UserQueryBuilder — declarative pipeline using QUERY_COMPONENTS, no raw query hashes inline
  • Uses ADVANCED_QUERY_SYNTAX_REGEX (the canonical regex) instead of the narrower ad-hoc regex previously in UserClassProxy
  • Field visibility (email for admins only) now driven by can_admin_all_resources? on the user object, consistent with the rest of the codebase

QueryBuilder base class improvements:

  • QUERY_COMPONENTS + each_component pipeline generalised into QueryBuilder base 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) and prepare_options (override to set dynamic options before the pipeline runs)
  • by_fuzzy_text, by_multi_match_query, and by_simple_query_string in Search::Elastic::Queries normalised to read fields from options[:fields] rather than accepting it as a separate argument; by_multi_match_query and by_simple_query_string made private since they are internal to by_full_text
  • VulnerabilityQueryBuilder migrated to inherit the base class pipeline, removing its duplicate build and each_component

Rollback safety:

  • Gated behind search_users_use_query_builder (gitlab_com_derisk) feature flag with current_user as the actor
  • Legacy code path preserved in UserClassProxy#legacy_query_hash until the flag is removed in a follow-up

References

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

  1. Enable Elasticsearch in GDK and ensure it's running
  2. Enable Advanced Search: Admin → Settings → Advanced Search → Enable
  3. Enable the feature flag (this MR's new code path):
    Feature.enable(:search_users_use_query_builder)
    To test the legacy path:
    Feature.disable(:search_users_use_query_builder)
  4. 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 @bob in 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.

Edited by Terri Chu

Merge request reports

Loading