From d8e65143b0e464cfc0aa1a1d7c51250f331e48bc Mon Sep 17 00:00:00 2001
From: Andrei Zubov <azubov@gitlab.com>
Date: Tue, 27 Feb 2024 21:15:00 +0000
Subject: [PATCH] Add option to keep shared groups when filtered by parent

Now it is possible to include invited groups when searching for groups
by parent. This will allow users to select invited groups when
protecting an environment

Changelog: changed
MR: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/141462
EE: true
---
 .../settings/api/access_dropdown_api.js       |  5 +--
 .../settings/components/access_dropdown.vue   |  2 +-
 app/finders/groups_finder.rb                  | 31 +++++++++++++++---
 app/models/group.rb                           | 21 ++++++++++++
 .../autocomplete/group_subgroups_finder.rb    |  7 +++-
 .../group_subgroups_finder_spec.rb            | 25 ++++++++++++++-
 spec/finders/groups_finder_spec.rb            | 32 +++++++++++++++++--
 spec/models/group_spec.rb                     | 31 ++++++++++++++++++
 8 files changed, 142 insertions(+), 12 deletions(-)

diff --git a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
index 37f95a7ab30ac..e232b1977f0a2 100644
--- a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
+++ b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
@@ -7,15 +7,16 @@ const buildUrl = (urlRoot, url) => {
   return joinPaths(urlRoot, url);
 };
 
-const defaultOptions = { includeParentDescendants: false };
+const defaultOptions = { includeParentDescendants: false, includeParentSharedGroups: false };
 
 export const getSubGroups = (options = defaultOptions) => {
-  const { includeParentDescendants } = options;
+  const { includeParentDescendants, includeParentSharedGroups } = options;
 
   return axios.get(buildUrl(gon.relative_url_root || '', GROUP_SUBGROUPS_PATH), {
     params: {
       group_id: gon.current_group_id,
       include_parent_descendants: includeParentDescendants,
+      include_parent_shared_groups: includeParentSharedGroups,
     },
   });
 };
diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
index fee1383c8e251..327f73b88c20b 100644
--- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
@@ -96,7 +96,7 @@ export default {
         Promise.all([
           this.groups.length
             ? Promise.resolve({ data: this.groups })
-            : getSubGroups({ includeParentDescendants: true }),
+            : getSubGroups({ includeParentDescendants: true, includeParentSharedGroups: true }),
         ])
           .then(([groupsResponse]) => {
             this.consolidateData(groupsResponse.data);
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index ba691bcdf0f29..c89722327ecf0 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -16,6 +16,9 @@
 #     filter_group_ids: array of integers - only include groups from the specified list of ids
 #     include_parent_descendants: boolean (defaults to false) - includes descendant groups when
 #                                 filtering by parent. The parent param must be present.
+#     include_parent_shared_groups: boolean (defaults to false) - includes shared groups of a parent group
+#                                 when filtering by parent.
+#                                 Both parent and include_parent_descendants params must be present.
 #     include_ancestors: boolean (defaults to true)
 #     organization: Scope the groups to the Organizations::Organization
 #
@@ -118,17 +121,27 @@ def by_organization(groups)
     groups.in_organization(organization)
   end
 
-  # rubocop: disable CodeReuse/ActiveRecord
   def by_parent(groups)
     return groups unless params[:parent]
 
-    if params.fetch(:include_parent_descendants, false)
-      groups.id_in(params[:parent].descendants)
+    if include_parent_descendants?
+      by_parent_descendants(groups, params[:parent])
     else
-      groups.where(parent: params[:parent])
+      by_parent_children(groups, params[:parent])
+    end
+  end
+
+  def by_parent_descendants(groups, parent)
+    if include_parent_shared_groups?
+      groups.descendants_with_shared_with_groups(parent)
+    else
+      groups.id_in(parent.descendants)
     end
   end
-  # rubocop: enable CodeReuse/ActiveRecord
+
+  def by_parent_children(groups, parent)
+    groups.by_parent(parent)
+  end
 
   def filter_group_ids(groups)
     return groups unless params[:filter_group_ids]
@@ -154,6 +167,14 @@ def sort(groups)
     groups.sort_by_attribute(params[:sort])
   end
 
+  def include_parent_shared_groups?
+    params.fetch(:include_parent_shared_groups, false)
+  end
+
+  def include_parent_descendants?
+    params.fetch(:include_parent_descendants, false)
+  end
+
   def min_access_level?
     current_user && params[:min_access_level].present?
   end
diff --git a/app/models/group.rb b/app/models/group.rb
index d8fc468e7344f..80fcd4470a044 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -239,6 +239,27 @@ def of_ancestors_and_self
       .where(group_group_links: { shared_group_id: group.self_and_ancestors })
   end
 
+  # Returns all groups that are shared with the given group (see :shared_with_group)
+  # and all descendents of the given group
+  # returns none if the given group is nil
+  scope :descendants_with_shared_with_groups, -> (group) do
+    return none if group.nil?
+
+    descendants_query = group.descendants.select(:id)
+    # since we're only interested in ids, we query GroupGroupLink directly instead of using :shared_with_group
+    # to avoid an extra JOIN in the resulting query
+    shared_groups_query = GroupGroupLink
+      .where(shared_group_id: group.id)
+      .select('shared_with_group_id AS id')
+
+    combined_query = Group
+      .from_union(descendants_query, shared_groups_query, alias_as: :combined)
+      .unscope(where: :type)
+      .select(:id)
+
+    id_in(combined_query)
+  end
+
   # WARNING: This method should never be used on its own
   # please do make sure the number of rows you are filtering is small
   # enough for this query
diff --git a/ee/app/finders/autocomplete/group_subgroups_finder.rb b/ee/app/finders/autocomplete/group_subgroups_finder.rb
index d150de44d0528..36bbd05248001 100644
--- a/ee/app/finders/autocomplete/group_subgroups_finder.rb
+++ b/ee/app/finders/autocomplete/group_subgroups_finder.rb
@@ -15,6 +15,10 @@ def include_parent_descendants?
       Gitlab::Utils.to_boolean(params[:include_parent_descendants])
     end
 
+    def include_parent_shared_groups?
+      Gitlab::Utils.to_boolean(params[:include_parent_shared_groups])
+    end
+
     def group_id
       params[:group_id]
     end
@@ -23,7 +27,8 @@ def group_id
     def execute
       group = ::Autocomplete::GroupFinder.new(current_user, nil, group_id: group_id).execute
       GroupsFinder.new(current_user, parent: group,
-        include_parent_descendants: include_parent_descendants?).execute.limit(LIMIT)
+        include_parent_descendants: include_parent_descendants?,
+        include_parent_shared_groups: include_parent_shared_groups?).execute.limit(LIMIT)
     end
     # rubocop: enable CodeReuse/Finder
   end
diff --git a/ee/spec/finders/autocomplete/group_subgroups_finder_spec.rb b/ee/spec/finders/autocomplete/group_subgroups_finder_spec.rb
index bf2a3974c4c8b..ac416291199a8 100644
--- a/ee/spec/finders/autocomplete/group_subgroups_finder_spec.rb
+++ b/ee/spec/finders/autocomplete/group_subgroups_finder_spec.rb
@@ -2,7 +2,7 @@
 
 require 'spec_helper'
 
-RSpec.describe Autocomplete::GroupSubgroupsFinder do
+RSpec.describe Autocomplete::GroupSubgroupsFinder, feature_category: :groups_and_projects do
   describe '#execute' do
     let_it_be(:group) { create(:group, :private) }
     let_it_be(:subgroup_1) { create(:group, :private, parent: group) }
@@ -10,10 +10,17 @@
     let_it_be(:grandchild_1) { create(:group, :private, parent: subgroup_1) }
     let_it_be(:member_in_group) { create(:user).tap { |u| group.add_reporter(u) } }
     let_it_be(:member_in_subgroup) { create(:user).tap { |u| subgroup_1.add_reporter(u) } }
+    let_it_be(:invited_to_group) { create(:group, :public) }
+    let_it_be(:invited_to_subgroup) { create(:group, :public) }
 
     let(:params) { { group_id: group.id } }
     let(:current_user) { member_in_group }
 
+    before do
+      group.shared_with_groups << invited_to_group
+      grandchild_1.shared_with_groups << invited_to_subgroup
+    end
+
     subject { described_class.new(current_user, params).execute }
 
     it 'returns subgroups', :aggregate_failures do
@@ -21,6 +28,22 @@
       expect(subject).to contain_exactly(subgroup_1, subgroup_2)
     end
 
+    context 'when include_parent_shared_groups parameter is true' do
+      before do
+        params[:include_parent_shared_groups] = true
+        params[:include_parent_descendants] = true
+      end
+
+      it 'returns subgroups and shared groups' do
+        expect(subject.count).to eq(4)
+        expect(subject).to contain_exactly(
+          subgroup_1,
+          subgroup_2,
+          grandchild_1,
+          invited_to_group)
+      end
+    end
+
     context 'when include_parent_descendants parameter is true' do
       before do
         params[:include_parent_descendants] = true
diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb
index b9add5b7db2de..c1ca19e29ec44 100644
--- a/spec/finders/groups_finder_spec.rb
+++ b/spec/finders/groups_finder_spec.rb
@@ -180,17 +180,29 @@
       let_it_be(:internal_sub_subgroup) { create(:group, :internal, parent: public_subgroup) }
       let_it_be(:private_sub_subgroup) { create(:group, :private, parent: public_subgroup) }
       let_it_be(:public_sub_subgroup) { create(:group, :public, parent: public_subgroup) }
+      let_it_be(:invited_to_group) { create(:group, :public) }
+      let_it_be(:invited_to_subgroup) { create(:group, :public) }
 
       let(:params) { { include_parent_descendants: true, parent: parent_group } }
 
+      before do
+        parent_group.shared_with_groups << invited_to_group
+        public_subgroup.shared_with_groups << invited_to_subgroup
+      end
+
       context 'with nil parent' do
-        it 'returns all accessible groups' do
+        before do
           params[:parent] = nil
+        end
+
+        it 'returns all accessible groups' do
           expect(described_class.new(user, params).execute).to contain_exactly(
             parent_group,
             public_subgroup,
             internal_sub_subgroup,
-            public_sub_subgroup
+            public_sub_subgroup,
+            invited_to_group,
+            invited_to_subgroup
           )
         end
       end
@@ -227,6 +239,22 @@
             private_sub_subgroup
           )
         end
+
+        context 'when include shared groups is set' do
+          before do
+            params[:include_parent_shared_groups] = true
+          end
+
+          it 'returns all group descendants with shared groups' do
+            expect(described_class.new(user, params).execute).to contain_exactly(
+              public_subgroup,
+              public_sub_subgroup,
+              internal_sub_subgroup,
+              private_sub_subgroup,
+              invited_to_group
+            )
+          end
+        end
       end
     end
 
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index f9f18a8b23e8a..95c4af9c41c4b 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -1306,6 +1306,37 @@
 
       it { is_expected.to match_array(groups) }
     end
+
+    describe 'descendants_with_shared_with_groups' do
+      subject { described_class.descendants_with_shared_with_groups(parent_group) }
+
+      let_it_be(:grand_parent_group) { create(:group, :public) }
+      let_it_be(:parent_group) { create(:group, :public, parent: grand_parent_group) }
+      let_it_be(:subgroup) { create(:group, :public, parent: parent_group) }
+      let_it_be(:subsubgroup) { create(:group, :public, parent: subgroup) }
+
+      let_it_be(:shared_to_group) { create(:group, :public) }
+      let_it_be(:shared_to_sub_group) { create(:group, :public) }
+
+      context 'when parent group is nil' do
+        let(:parent_group) { nil }
+
+        it { is_expected.to match_array([]) }
+      end
+
+      context 'when parent group is present and there are shared groups' do
+        before do
+          parent_group.shared_with_groups << shared_to_group
+          subgroup.shared_with_groups << shared_to_sub_group
+        end
+
+        it { is_expected.to match_array([subgroup, subsubgroup, shared_to_group]) }
+      end
+
+      context 'when parent group is present and there are no shared groups' do
+        it { is_expected.to match_array([subgroup, subsubgroup]) }
+      end
+    end
   end
 
   describe '#to_reference' do
-- 
GitLab