From 8217c1ac948c9022805f61d3fdd43ff2d527dd21 Mon Sep 17 00:00:00 2001 From: Madelein van Niekerk <mvanniekerk@gitlab.com> Date: Wed, 12 Mar 2025 20:21:11 +0200 Subject: [PATCH] Add group and project scope for basic users autocomplete Changelog: changed --- app/finders/users_finder.rb | 44 +++++ app/models/member.rb | 2 + ..._to_authorized_namespaces_basic_search.yml | 9 + ...horized_namespaces_basic_search_by_ids.yml | 9 + lib/gitlab/search_results.rb | 27 ++- spec/finders/users_finder_spec.rb | 71 ++++++++ spec/helpers/search_helper_spec.rb | 24 +++ spec/lib/gitlab/search_results_spec.rb | 158 +++++++++++++++++- 8 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search.yml create mode 100644 config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search_by_ids.yml diff --git a/app/finders/users_finder.rb b/app/finders/users_finder.rb index d0ed3515bfd66..d2b4b18f9207f 100644 --- a/app/finders/users_finder.rb +++ b/app/finders/users_finder.rb @@ -55,6 +55,8 @@ def execute users = by_custom_attributes(users) users = by_non_internal(users) users = by_without_project_bots(users) + users = by_membership(users) + users = by_member_source_ids(users) order(users) end @@ -180,6 +182,48 @@ def by_without_project_bots(users) users.without_project_bot end + def by_membership(users) + return users unless params[:by_membership] + + group_members = Member + .non_request + .with_source(current_user.authorized_groups.self_and_ancestors) + .select(:user_id) + .to_sql + + project_members = Member + .non_request + .with_source(current_user.authorized_projects) + .select(:user_id) + .to_sql + + query = "users.id IN (#{group_members} UNION #{project_members})" + users.where(query) # rubocop: disable CodeReuse/ActiveRecord -- finder + end + + def by_member_source_ids(users) + group_member_source_ids = params[:group_member_source_ids] + project_member_source_ids = params[:project_member_source_ids] + + return users unless group_member_source_ids || project_member_source_ids + + member_queries = [] + + if group_member_source_ids.present? + member_queries << Member.with_source_id(group_member_source_ids).with_source_type('Namespace') + end + + if project_member_source_ids.present? + member_queries << Member.with_source_id(project_member_source_ids).with_source_type('Project') + end + + return users if member_queries.empty? + + member_query = member_queries.reduce(:or).non_request + + users.id_in(member_query.select(:user_id)) + end + def order(users) return users unless params[:sort] diff --git a/app/models/member.rb b/app/models/member.rb index deffb3768a7ad..614315657e03a 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -209,6 +209,8 @@ class Member < ApplicationRecord end scope :with_source_id, ->(source_id) { where(source_id: source_id) } + scope :with_source, ->(source) { where(source: source) } + scope :with_source_type, ->(source_type) { where(source_type: source_type) } scope :including_source, -> { includes(:source) } scope :including_user, -> { includes(:user) } diff --git a/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search.yml b/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search.yml new file mode 100644 index 0000000000000..b7c9a64f67675 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search.yml @@ -0,0 +1,9 @@ +--- +name: users_search_scoped_to_authorized_namespaces_basic_search +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442091 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182557 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/520710 +milestone: '17.10' +group: group::global search +type: gitlab_com_derisk +default_enabled: false diff --git a/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search_by_ids.yml b/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search_by_ids.yml new file mode 100644 index 0000000000000..fd4fbcbaa7c40 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/users_search_scoped_to_authorized_namespaces_basic_search_by_ids.yml @@ -0,0 +1,9 @@ +--- +name: users_search_scoped_to_authorized_namespaces_basic_search_by_ids +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/442091 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/182557 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/524297 +milestone: '17.10' +group: group::global search +type: gitlab_com_derisk +default_enabled: false diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index b94184cfe8c6d..c795c12b157eb 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -109,7 +109,18 @@ def count_limit def users return User.none unless Ability.allowed?(current_user, :read_users_list) - UsersFinder.new(current_user, { search: query, use_minimum_char_limit: false }).execute + params = { search: query, use_minimum_char_limit: false } + + if current_user && filters[:autocomplete] + if Feature.enabled?(:users_search_scoped_to_authorized_namespaces_basic_search, current_user) + params[:by_membership] = true + elsif Feature.enabled?(:users_search_scoped_to_authorized_namespaces_basic_search_by_ids, current_user) + params[:group_member_source_ids] = current_user_authorized_group_ids + params[:project_member_source_ids] = current_user_authorized_project_ids + end + end + + UsersFinder.new(current_user, params).execute end # highlighting is only performed by Elasticsearch backed results @@ -266,6 +277,20 @@ def issuable_params end end + def current_user_authorized_group_ids + GroupsFinder + .new(current_user, { all_available: false }) + .execute + .pluck("#{Group.table_name}.#{Group.primary_key}") # rubocop: disable CodeReuse/ActiveRecord -- need to find ids + end + + def current_user_authorized_project_ids + ProjectsFinder + .new(current_user: current_user, params: { non_public: true }) + .execute + .pluck_primary_key + end + # rubocop: disable CodeReuse/ActiveRecord def limited_count(relation) relation.reorder(nil).limit(count_limit).size diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb index a766923a69ac3..770d7d6aeaed7 100644 --- a/spec/finders/users_finder_spec.rb +++ b/spec/finders/users_finder_spec.rb @@ -149,6 +149,77 @@ users = described_class.new(user, admins: true).execute expect(users).to contain_exactly(user, normal_user, external_user, admin_user, unconfirmed_user, omniauth_user, internal_user, project_bot, service_account_user) end + + context 'when filtering by_membership' do + let_it_be(:group_user) { create(:user) } + let_it_be(:project_user) { create(:user) } + let_it_be(:group) { create(:group, developers: [user]) } + let_it_be(:project) { create(:project, developers: [user]) } + + subject(:users) { described_class.new(user, by_membership: true).execute } + + it 'includes the user and project owner' do + expect(users).to contain_exactly(user, project.owner) + end + + it 'includes users who are members of the user groups' do + group.add_developer(group_user) + + expect(users).to contain_exactly(user, project.owner, group_user) + end + + it 'includes users who are members of the user projects' do + project.add_developer(project_user) + + expect(users).to contain_exactly(user, project.owner, project_user) + end + end + + context 'when filtering by_member_source_ids' do + let_it_be(:group_user) { create(:user) } + let_it_be(:project_user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project) } + + it 'filters by group membership' do + group.add_developer(group_user) + + users = described_class.new(user, group_member_source_ids: [group.id]).execute + + expect(users).to contain_exactly(group_user) + end + + it 'filters by project membership' do + project.add_developer(project_user) + + users = described_class.new(user, project_member_source_ids: [project.id]).execute + + expect(users).to contain_exactly(project_user, project.owner) + end + + it 'filters by group and project membership' do + group.add_developer(group_user) + project.add_developer(project_user) + + users = described_class + .new(user, group_member_source_ids: [group.id], project_member_source_ids: [project.id]) + .execute + + expect(users).to contain_exactly(group_user, project_user, project.owner) + end + + it 'does not include members not part of the filtered group' do + users = described_class.new(user, group_member_source_ids: [group.id]).execute + + expect(users).not_to include(group_user) + end + + it 'does not include members not part of the filtered project' do + users = described_class.new(user, project_member_source_ids: [project.id]).execute + + expect(users).not_to include(project_user) + end + end end shared_examples 'executes users finder as admin' do diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 36544a75648d5..782d49f496823 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -63,8 +63,10 @@ def simple_sanitize(str) shared_examples 'for users' do let_it_be(:another_user) { create(:user, name: 'Jane Doe') } let(:term) { 'jane' } + let_it_be(:project) { create(:project, developers: user) } it 'returns users matching the term' do + project.add_developer(another_user) result = search_autocomplete_opts(term) expect(result.size).to eq(1) expect(result.first[:id]).to eq(another_user.id) @@ -97,21 +99,29 @@ def simple_sanitize(str) it 'includes users with matching public emails' do public_email_user + project.add_developer(public_email_user) + expect(ids).to include(public_email_user.id) end it 'includes users in forbidden states' do banned_user + project.add_developer(banned_user) + expect(ids).to include(banned_user.id) end it 'includes users without matching public emails but with matching private emails' do private_email_user + project.add_developer(private_email_user) + expect(ids).to include(private_email_user.id) end it 'includes users matching on secondary email' do secondary_email + project.add_developer(user_with_other_email) + expect(ids).to include(secondary_email.user_id) end end @@ -123,21 +133,29 @@ def simple_sanitize(str) it 'includes users with matching public emails' do public_email_user + project.add_developer(public_email_user) + expect(ids).to include(public_email_user.id) end it 'does not include users in forbidden states' do banned_user + project.add_developer(banned_user) + expect(ids).not_to include(banned_user.id) end it 'does not include users without matching public emails but with matching private emails' do private_email_user + project.add_developer(private_email_user) + expect(ids).not_to include(private_email_user.id) end it 'does not include users matching on secondary email' do secondary_email + project.add_developer(secondary_email) + expect(ids).not_to include(secondary_email.user_id) end end @@ -146,6 +164,12 @@ def simple_sanitize(str) context 'with limiting' do let_it_be(:users) { create_list(:user, 6, name: 'Jane Doe') } + before do + users.each do |user| + project.add_developer(user) + end + end + it 'only returns the first 5 users' do result = search_autocomplete_opts(term) expect(result.size).to eq(5) diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index b15af98e1d9b5..42971c087fc7f 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -7,7 +7,7 @@ include SearchHelpers using RSpec::Parameterized::TableSyntax - let_it_be(:user) { create(:user) } + let_it_be(:user) { create(:user, username: 'foobar') } let_it_be(:project) { create(:project, name: 'foo') } let_it_be(:issue) { create(:issue, project: project, title: 'foo') } let_it_be(:milestone) { create(:milestone, project: project, title: 'foo') } @@ -313,18 +313,166 @@ end describe '#users' do + subject(:user_search_result) { results.objects('users') } + + let_it_be(:another_user) { create(:user, username: 'barfoo') } + let_it_be(:group) { create(:group) } + it 'does not call the UsersFinder when the current_user is not allowed to read users list' do allow(Ability).to receive(:allowed?).and_return(false) - expect(UsersFinder).not_to receive(:new).with(user, { search: 'foo', use_minimum_char_limit: false }).and_call_original + expect(UsersFinder).not_to receive(:new) - results.objects('users') + user_search_result end it 'calls the UsersFinder' do - expect(UsersFinder).to receive(:new).with(user, { search: 'foo', use_minimum_char_limit: false }).and_call_original + expected_params = { + search: 'foo', + use_minimum_char_limit: false + } + + expect(UsersFinder).to receive(:new).with(user, expected_params).and_call_original + + user_search_result + end + + context 'when the autocomplete filter is added' do + let(:filters) { { autocomplete: true } } + + shared_examples 'returns users' do + it 'returns the current_user since they match the query' do + expect(user_search_result).to match_array(user) + end + + context 'when another user belongs to a project the current_user belongs to' do + before do + project.add_developer(another_user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when another user belongs to a group' do + before do + group.add_developer(another_user) + end + + it 'does not include the other user' do + expect(user_search_result).not_to include(another_user) + end + + context 'when the current_user also belongs to that group' do + before do + group.add_developer(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when the current_user belongs to a parent of the group' do + let_it_be(:parent_group) { create(:group) } + let_it_be(:group) { create(:group, parent: parent_group) } + + before do + parent_group.add_developer(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when the current_user belongs to a group that is shared by the group' do + let_it_be_with_reload(:shared_with_group) { create(:group) } + let_it_be_with_reload(:group_group_link) do + create( + :group_group_link, + group_access: ::Gitlab::Access::GUEST, + shared_group: group, + shared_with_group: shared_with_group + ) + end + + before do + shared_with_group.add_developer(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when the current_user belongs to a child of the group' do + let_it_be(:child_group) { create(:group, parent: group) } + + before do + child_group.add_developer(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + end + + context 'when another user is a guest of a private group' do + let_it_be(:private_group) { create(:group, :private) } + + before do + private_group.add_guest(another_user) + end - results.objects('users') + it 'does not include the other user' do + expect(user_search_result).to match_array(user) + end + + context 'when the current_user is a guest of the private group' do + before do + private_group.add_guest(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + + context 'when the current_user is a guest of the public parent of the private group' do + let_it_be(:public_parent_group) { create(:group, :public) } + let_it_be(:private_group) { create(:group, :private, parent: public_parent_group) } + + before do + public_parent_group.add_guest(user) + end + + it 'includes the other user' do + expect(user_search_result).to match_array([user, another_user]) + end + end + end + end + + context 'when users_search_scoped_to_authorized_namespaces_basic_search is enabled' do + before do + stub_feature_flags(users_search_scoped_to_authorized_namespaces_basic_search: true) + stub_feature_flags(users_search_scoped_to_authorized_namespaces_basic_search_by_ids: false) + end + + include_examples 'returns users' + end + + context 'when users_search_scoped_to_authorized_namespaces_basic_search_by_ids is enabled' do + before do + stub_feature_flags(users_search_scoped_to_authorized_namespaces_basic_search_by_ids: true) + stub_feature_flags(users_search_scoped_to_authorized_namespaces_basic_search: false) + end + + include_examples 'returns users' + end end end end -- GitLab