From 521f95f1b9815572e4ae32bf1d5ad93ad06fc5f5 Mon Sep 17 00:00:00 2001
From: Hinam Mehra <hmehra@gitlab.com>
Date: Fri, 30 Aug 2024 07:18:16 +0000
Subject: [PATCH] Update billed_shared_group_members to include group links

- When calculating billed members for a group-link,
include all non-guest users and elevated guests
from the invited group.
---
 app/models/group_group_link.rb       |   3 +-
 app/models/project_group_link.rb     |   2 +-
 ee/app/models/ee/group.rb            |  47 +++++-
 ee/app/models/ee/group_group_link.rb |   2 +
 ee/lib/ee/api/members.rb             |   5 +-
 ee/spec/models/ee/group_spec.rb      | 204 +++++++++++++++++++++++++++
 6 files changed, 256 insertions(+), 7 deletions(-)

diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index 8ae74fa60e497..470f62e2e797a 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -12,7 +12,8 @@ class GroupGroupLink < ApplicationRecord
   validates :shared_with_group, presence: true
   validates :group_access, inclusion: { in: Gitlab::Access.all_values }, presence: true
 
-  scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) }
+  scope :guests, -> { where(group_access: Gitlab::Access::GUEST) }
+  scope :non_guests, -> { where('group_group_links.group_access > ?', Gitlab::Access::GUEST) }
   scope :for_shared_groups, ->(group_ids) { where(shared_group_id: group_ids) }
 
   scope :with_owner_or_maintainer_access, -> do
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index 95e83d96c1037..ed6bd21ec7c28 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -14,7 +14,7 @@ class ProjectGroupLink < ApplicationRecord
   validates :group_access, inclusion: { in: Gitlab::Access.all_values }, presence: true
   validate :different_group
 
-  scope :non_guests, -> { where('group_access > ?', Gitlab::Access::GUEST) }
+  scope :non_guests, -> { where('project_group_links.group_access > ?', Gitlab::Access::GUEST) }
   scope :in_group, ->(group_ids) { where(group_id: group_ids) }
   scope :for_projects, ->(project_ids) { where(project_id: project_ids) }
 
diff --git a/ee/app/models/ee/group.rb b/ee/app/models/ee/group.rb
index d7ede467da1f4..fe3af8905d59a 100644
--- a/ee/app/models/ee/group.rb
+++ b/ee/app/models/ee/group.rb
@@ -143,6 +143,12 @@ module Group
           .merge(guests_scope)
       end
 
+      scope :invited_groups_with_guest_member_role, ->(group) do
+        joins(:shared_group_links)
+          .where(group_group_links: { shared_group_id: group.self_and_descendants })
+          .merge(::GroupGroupLink.guests.with_custom_role)
+      end
+
       scope :invited_groups_in_projects_for_hierarchy, ->(group, exclude_guests = false) do
         guests_scope = exclude_guests ? ::ProjectGroupLink.non_guests : ::ProjectGroupLink.all
 
@@ -921,7 +927,28 @@ def billed_shared_group_users(exclude_guests: false)
 
     def billed_shared_group_members(exclude_guests: false)
       groups = self.class.invited_groups_in_groups_for_hierarchy(self, exclude_guests)
-      invited_or_shared_group_members(groups, exclude_guests: exclude_guests).not_banned_in(root_ancestor)
+
+      # gets all billable members from group-invites with access_level > GUEST
+      members = invited_or_shared_group_members(groups, exclude_guests: exclude_guests)
+
+      # gets all billable members from group-invites with access_level = GUEST + custom_role
+      members_with_custom_role = billed_shared_guest_group_members_with_custom_role(exclude_guests: exclude_guests)
+
+      # merge both and return
+      # see acceptance criteria - https://gitlab.com/gitlab-org/gitlab/-/issues/443369#note_2045035173
+      ::GroupMember.from_union([members, members_with_custom_role]).not_banned_in(root_ancestor)
+    end
+
+    def billed_shared_guest_group_members_with_custom_role(exclude_guests: false)
+      return ::GroupMember.none unless exclude_guests
+      return ::GroupMember.none unless ::Feature.enabled?(:assign_custom_roles_to_group_links_saas, self)
+
+      # invited groups that have access_level = GUEST + custom_role
+      groups = self.class.invited_groups_with_guest_member_role(self)
+
+      # get all members from those invited groups that have
+      # access_level = GUEST + custom_role (that has occupies_seat = TRUE)
+      members_with_elevating_guest_member_role(groups)
     end
 
     # Members belonging to Groups invited to collaborate with Projects
@@ -1107,11 +1134,23 @@ def billed_user_ids_including_guests
     end
 
     def invited_or_shared_group_members(groups, exclude_guests: false)
-      guests_scope = exclude_guests ? ::GroupMember.non_guests : ::GroupMember.all
+      non_guests_scope = if ::Feature.enabled?(:assign_custom_roles_to_group_links_saas, self)
+                           ::GroupMember.with_elevated_guests
+                         else
+                           ::GroupMember.non_guests
+                         end
+
+      guests_scope = exclude_guests ? non_guests_scope : ::GroupMember.all
+
+      ::GroupMember.active_without_invites_and_requests
+        .with_source_id(groups.self_and_ancestors)
+        .merge(guests_scope)
+    end
 
+    def members_with_elevating_guest_member_role(groups)
       ::GroupMember.active_without_invites_and_requests
-                   .with_source_id(groups.self_and_ancestors)
-                   .merge(guests_scope)
+        .with_source_id(groups.self_and_ancestors)
+        .merge(::GroupMember.elevated_guests)
     end
 
     def users_without_bots(members, merge_condition: ::User.all)
diff --git a/ee/app/models/ee/group_group_link.rb b/ee/app/models/ee/group_group_link.rb
index 6cd23f04bddd0..c9b20ee247f9c 100644
--- a/ee/app/models/ee/group_group_link.rb
+++ b/ee/app/models/ee/group_group_link.rb
@@ -15,6 +15,8 @@ module GroupGroupLink
       scope :in_shared_group, ->(shared_groups) { where(shared_group: shared_groups) }
       scope :not_in_shared_with_group, ->(shared_with_groups) { where.not(shared_with_group: shared_with_groups) }
 
+      scope :with_custom_role, -> { where.not(member_role_id: nil) }
+
       validate :group_with_allowed_email_domains
     end
 
diff --git a/ee/lib/ee/api/members.rb b/ee/lib/ee/api/members.rb
index 47752879f70b0..d19c71139f590 100644
--- a/ee/lib/ee/api/members.rb
+++ b/ee/lib/ee/api/members.rb
@@ -212,7 +212,10 @@ module Members
             invited_group_to_project_memberships = group.billed_invited_group_to_project_members(exclude_guests: exclude_guests).with_user(user.id)
             invited_group_to_group_memberships = group.billed_shared_group_members(exclude_guests: exclude_guests).with_user(user.id)
 
-            invited_memberships = invited_group_to_group_memberships.or(invited_group_to_project_memberships)
+            invited_memberships = ::GroupMember.from_union([
+              invited_group_to_group_memberships,
+              invited_group_to_project_memberships
+            ])
 
             present paginate(invited_memberships), with: ::EE::API::Entities::BillableMembership
           end
diff --git a/ee/spec/models/ee/group_spec.rb b/ee/spec/models/ee/group_spec.rb
index dc59db7f75000..252e69593c509 100644
--- a/ee/spec/models/ee/group_spec.rb
+++ b/ee/spec/models/ee/group_spec.rb
@@ -1703,6 +1703,210 @@
         expect(group.billed_shared_group_members.map(&:user)).to exclude(banned_user)
       end
     end
+
+    context 'with member roles' do
+      let_it_be(:group) { create(:group) }
+      let_it_be(:invited_group) { create(:group) }
+
+      let_it_be(:guest_elevated) { create(:member_role, :guest, :read_vulnerability) }
+      let_it_be(:guest_basic) { create(:member_role, :guest, :read_code) }
+      let_it_be(:developer_lead) { create(:member_role, :developer, :admin_vulnerability) }
+
+      let_it_be(:member_a) { create(:group_member, :guest, source: invited_group) }
+      let_it_be(:member_b) { create(:group_member, :guest, source: invited_group, member_role: guest_elevated) }
+      let_it_be(:member_c) { create(:group_member, :guest, source: invited_group, member_role: guest_basic) }
+      let_it_be(:member_d) { create(:group_member, :developer, source: invited_group) }
+      let_it_be(:member_e) { create(:group_member, :developer, source: invited_group, member_role: developer_lead) }
+
+      let_it_be(:invited_access_level) { :guest }
+      let_it_be(:invited_member_role) { nil }
+
+      before do
+        create(
+          :group_group_link,
+          invited_access_level,
+          shared_with_group: invited_group,
+          shared_group: group,
+          member_role: invited_member_role
+        )
+      end
+
+      shared_examples 'returns all members of the invited group' do
+        it do
+          expect(group.billed_shared_group_members)
+            .to match_array([member_a, member_b, member_c, member_d, member_e])
+        end
+      end
+
+      shared_examples 'returns empty array' do
+        it do
+          expect(group.billed_shared_group_members(exclude_guests: true)).to match_array([])
+        end
+      end
+
+      shared_examples 'returns elevated guests from invited group' do
+        it do
+          expect(group.billed_shared_group_members(exclude_guests: true)).to match_array([member_b])
+        end
+      end
+
+      shared_examples 'returns non-guests and elevated guests from invited group' do
+        it do
+          expect(group.billed_shared_group_members(exclude_guests: true)).to match_array([member_b, member_d, member_e])
+        end
+      end
+
+      shared_examples 'returns non-guests from invited group' do
+        it do
+          expect(group.billed_shared_group_members(exclude_guests: true)).to match_array([member_d, member_e])
+        end
+      end
+
+      context 'when group link is assigned a guest role' do
+        let(:invited_access_level) { :guest }
+        let(:invited_member_role) { nil }
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is enabled' do
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns empty array'
+          end
+        end
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is disabled' do
+          before do
+            stub_feature_flags(assign_custom_roles_to_group_links_saas: false)
+          end
+
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns empty array'
+          end
+        end
+      end
+
+      context 'when group link is assigned a guest member role that does not occupy a seat' do
+        let(:invited_access_level) { :guest }
+        let(:invited_member_role) { guest_basic }
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is enabled' do
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns elevated guests from invited group'
+          end
+        end
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is disabled' do
+          before do
+            stub_feature_flags(assign_custom_roles_to_group_links_saas: false)
+          end
+
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns empty array'
+          end
+        end
+      end
+
+      context 'when group link is assigned a guest member role that occupies a seat' do
+        let(:invited_access_level) { :guest }
+        let(:invited_member_role) { guest_elevated }
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is enabled' do
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns elevated guests from invited group'
+          end
+        end
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is disabled' do
+          before do
+            stub_feature_flags(assign_custom_roles_to_group_links_saas: false)
+          end
+
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns empty array'
+          end
+        end
+      end
+
+      context 'when group link is assigned a non-guest role' do
+        let(:invited_access_level) { :developer }
+        let(:invited_member_role) { nil }
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is enabled' do
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns non-guests and elevated guests from invited group'
+          end
+        end
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is disabled' do
+          before do
+            stub_feature_flags(assign_custom_roles_to_group_links_saas: false)
+          end
+
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns non-guests from invited group'
+          end
+        end
+      end
+
+      context 'when group link is assigned a non-guest custom role' do
+        let(:invited_access_level) { :developer }
+        let(:invited_member_role) { developer_lead }
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is enabled' do
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns non-guests and elevated guests from invited group'
+          end
+        end
+
+        context 'when feature-flag `assign_custom_roles_to_group_links_saas` is disabled' do
+          before do
+            stub_feature_flags(assign_custom_roles_to_group_links_saas: false)
+          end
+
+          context 'with guests' do
+            it_behaves_like 'returns all members of the invited group'
+          end
+
+          context 'without guests' do
+            it_behaves_like 'returns non-guests from invited group'
+          end
+        end
+      end
+    end
   end
 
   describe '#billed_invited_group_to_project_users' do
-- 
GitLab