From a2ec5c408808b82c5e75645f59b2fd3b94796bd1 Mon Sep 17 00:00:00 2001
From: Andrew Evans <aevans@gitlab.com>
Date: Wed, 17 Jan 2024 18:00:56 +0000
Subject: [PATCH] Service accounts can be added to LDAP-synced groups

See [425947](https://gitlab.com/gitlab-org/gitlab/-/issues/425947) for
details.

Allows adding service account users to groups even when LDAP sync is
enabled. To do this, we make some major changes:

- Add a new permission: `admin_service_account_members` ; this way we
  can check for group admin permissions even when those have been
  mostly disabled by setting the group to LDAP syncing
- Switch which permission we check in the service layer when creating
  new `Member` objects for groups <-> service account users
- Remove multiple layers of duplicative permission checking from the API
  and upper-level services

The new permission is only valid when the `service_accounts` licensed
feature is enabled.

Added some additional error handling to ensure there are no changes in
the API status codes or object returns.

Changelog: changed
EE: true
---
 app/services/members/create_service.rb        | 23 +++----
 app/services/members/creator_service.rb       |  4 +-
 app/services/members/destroy_service.rb       |  7 ++-
 .../auth/ldap/ldap-troubleshooting.md         |  6 ++
 ee/app/policies/ee/group_policy.rb            |  5 ++
 ee/app/services/ee/members/destroy_service.rb |  9 +++
 .../ee/members/groups/creator_service.rb      |  9 +++
 ee/spec/policies/group_policy_spec.rb         | 51 +++++++++++++++
 ee/spec/requests/api/members_spec.rb          | 62 +++++++++++++++++++
 .../ee/members/destroy_service_spec.rb        | 20 ++++++
 .../ee/members/groups/creator_service_spec.rb | 22 +++++++
 lib/api/helpers/members_helpers.rb            | 28 ++++++---
 lib/api/members.rb                            |  2 -
 spec/requests/api/members_spec.rb             | 18 ++++++
 spec/services/members/create_service_spec.rb  | 16 ++++-
 .../models/member_shared_examples.rb          |  2 +
 .../requests/api/members_shared_examples.rb   |  2 +-
 17 files changed, 255 insertions(+), 31 deletions(-)

diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index b453098e27ac..ec9a4f9f4a67 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -8,18 +8,19 @@ class CreateService < Members::BaseService
 
     DEFAULT_INVITE_LIMIT = 100
 
-    attr_reader :membership_locked
+    attr_reader :membership_locked, :http_status
 
     def initialize(*args)
       super
 
       @errors = []
+      @http_status = nil
       @invites = invites_from_params
       @source = params[:source]
     end
 
     def execute
-      raise Gitlab::Access::AccessDeniedError unless can?(current_user, create_member_permission(source), source)
+      validate_source_type!
 
       if adding_at_least_one_owner && cannot_assign_owner_responsibilities_to_member_in_project?
         raise Gitlab::Access::AccessDeniedError
@@ -65,6 +66,10 @@ def invites_from_params
       params[:user_id].to_s.split(',').uniq
     end
 
+    def validate_source_type!
+      raise "Unknown source type: #{source.class}!" unless source.is_a?(Group) || source.is_a?(Project)
+    end
+
     def validate_invite_source!
       raise ArgumentError, s_('AddMember|No invite source provided.') unless invite_source.present?
     end
@@ -102,6 +107,7 @@ def create_params
     end
 
     def process_result(member)
+      @http_status = :unauthorized if member.errors.added? :base, :unauthorized
       existing_errors = member.errors.full_messages
 
       # calling invalid? clears any errors that were added outside of the
@@ -174,7 +180,7 @@ def at_least_one_member_created?
 
     def result
       if errors.any?
-        error(formatted_errors)
+        error(formatted_errors, http_status)
       else
         success
       end
@@ -194,17 +200,6 @@ def publish_event!
         })
       )
     end
-
-    def create_member_permission(source)
-      case source
-      when Group
-        :admin_group_member
-      when Project
-        :admin_project_member
-      else
-        raise "Unknown source type: #{source.class}!"
-      end
-    end
   end
 end
 
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
index d7bf073d8e9f..57159c14b3b7 100644
--- a/app/services/members/creator_service.rb
+++ b/app/services/members/creator_service.rb
@@ -176,7 +176,7 @@ def can_commit_member?
       end
     end
 
-    # overridden in Members::Groups::CreatorService
+    # overridden in EE:Members::Groups::CreatorService
     def member_role_too_high?
       false
     end
@@ -243,7 +243,7 @@ def add_commit_error
               _('not authorized to update member')
             end
 
-      member.errors.add(:base, msg)
+      member.errors.add(:base, :unauthorized, message: msg)
     end
 
     def add_member_role_error
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index d4cc60c6de00..14cdb83b4e5d 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -185,7 +185,7 @@ def destroying_member_with_owner_access_level?(member)
     def destroy_member_permission(member)
       case member
       when GroupMember
-        :destroy_group_member
+        destroy_group_member_permission(member)
       when ProjectMember
         :destroy_project_member
       else
@@ -193,6 +193,11 @@ def destroy_member_permission(member)
       end
     end
 
+    # overridden in EE::Members::DestroyService
+    def destroy_group_member_permission(_member)
+      :destroy_group_member
+    end
+
     def destroy_bot_member_permission(member)
       raise "Unsupported bot member type: #{member}" unless member.is_a?(ProjectMember)
 
diff --git a/doc/administration/auth/ldap/ldap-troubleshooting.md b/doc/administration/auth/ldap/ldap-troubleshooting.md
index eb1ee2034697..39546345355e 100644
--- a/doc/administration/auth/ldap/ldap-troubleshooting.md
+++ b/doc/administration/auth/ldap/ldap-troubleshooting.md
@@ -393,6 +393,12 @@ the rails console.
    UIDs here should match the 'Identifier' from the LDAP identity checked earlier. If it doesn't,
    the user does not appear to be in the LDAP group.
 
+#### Cannot add service account user to group when LDAP sync is enabled
+
+When LDAP sync is enabled for a group, you cannot use the "invite" dialog to invite new group members.
+
+To resolve this issue in GitLab 16.8 and later, you can invite service accounts to and remove them from a group using the [group members API endpoints](../../../api/members.md#add-a-member-to-a-group-or-project).
+
 #### Administrator privileges not granted
 
 When [Administrator sync](ldap_synchronization.md#administrator-sync) has been configured
diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb
index cec0a34eb05e..960b24b43cd2 100644
--- a/ee/app/policies/ee/group_policy.rb
+++ b/ee/app/policies/ee/group_policy.rb
@@ -462,6 +462,7 @@ module GroupPolicy
 
       rule { (admin | owner) & service_accounts_available }.policy do
         enable :admin_service_accounts
+        enable :admin_service_account_member
       end
 
       rule { memberships_locked_to_saml & saml_group_links_enabled & ~admin }.policy do
@@ -522,6 +523,10 @@ module GroupPolicy
         enable :destroy_group_member
       end
 
+      rule { custom_roles_allowed & role_enables_admin_group_member & service_accounts_available }.policy do
+        enable :admin_service_account_member
+      end
+
       rule { custom_roles_allowed & role_enables_manage_group_access_tokens & resource_access_token_feature_available }.policy do
         enable :read_resource_access_tokens
         enable :destroy_resource_access_tokens
diff --git a/ee/app/services/ee/members/destroy_service.rb b/ee/app/services/ee/members/destroy_service.rb
index c5724276f6e7..945bdfabb8e6 100644
--- a/ee/app/services/ee/members/destroy_service.rb
+++ b/ee/app/services/ee/members/destroy_service.rb
@@ -118,6 +118,15 @@ def destroy_data_related_to_member(member, skip_subresources, skip_saml_identity
         ::User.clear_group_with_ai_available_cache(member.user.id)
       end
 
+      override :destroy_group_member_permission
+      def destroy_group_member_permission(member)
+        if member.user&.service_account?
+          :admin_service_account_member
+        else
+          super(member)
+        end
+      end
+
       def enqueue_cleanup_add_on_seat_assignments(member)
         namespace = member.source.root_ancestor
 
diff --git a/ee/app/services/ee/members/groups/creator_service.rb b/ee/app/services/ee/members/groups/creator_service.rb
index e06760a8799d..4e2de871c5d9 100644
--- a/ee/app/services/ee/members/groups/creator_service.rb
+++ b/ee/app/services/ee/members/groups/creator_service.rb
@@ -30,6 +30,15 @@ def member_attributes
           attributes.merge(member_role_id: member_role_id)
         end
 
+        override :can_create_new_member?
+        def can_create_new_member?
+          if member.user&.service_account?
+            current_user.can?(:admin_service_account_member, member.group)
+          else
+            current_user.can?(:admin_group_member, member.group)
+          end
+        end
+
         def ignore_user_limits
           args[:ignore_user_limits]
         end
diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb
index 51e4edd6558e..4438455c70c3 100644
--- a/ee/spec/policies/group_policy_spec.rb
+++ b/ee/spec/policies/group_policy_spec.rb
@@ -1339,6 +1339,14 @@ def stub_group_saml_config(enabled)
         it { is_expected.not_to be_allowed(:admin_group_member) }
         it { is_expected.not_to be_allowed(:override_group_member) }
         it { is_expected.not_to be_allowed(:update_group_member) }
+
+        context 'and service_accounts feature is enabled' do
+          before do
+            stub_licensed_features(service_accounts: true)
+          end
+
+          it { is_expected.to be_allowed(:admin_service_account_member) }
+        end
       end
     end
   end
@@ -2832,6 +2840,7 @@ def expect_private_group_permissions_as_if_non_member
       let(:current_user) { owner }
 
       it { is_expected.to be_disallowed(:admin_service_accounts) }
+      it { is_expected.to be_disallowed(:admin_service_account_member) }
     end
 
     context 'when feature is enabled' do
@@ -2843,12 +2852,14 @@ def expect_private_group_permissions_as_if_non_member
         let(:current_user) { maintainer }
 
         it { is_expected.to be_disallowed(:admin_service_accounts) }
+        it { is_expected.to be_disallowed(:admin_service_account_member) }
       end
 
       context 'when the user is an owner' do
         let(:current_user) { owner }
 
         it { is_expected.to be_allowed(:admin_service_accounts) }
+        it { is_expected.to be_allowed(:admin_service_account_member) }
       end
 
       context 'when the user is an instance admin' do
@@ -2856,10 +2867,12 @@ def expect_private_group_permissions_as_if_non_member
 
         context 'when admin mode is enabled', :enable_admin_mode do
           it { is_expected.to be_allowed(:admin_service_accounts) }
+          it { is_expected.to be_allowed(:admin_service_account_member) }
         end
 
         context 'when admin mode is not enabled' do
           it { is_expected.to be_disallowed(:admin_service_accounts) }
+          it { is_expected.to be_disallowed(:admin_service_account_member) }
         end
       end
     end
@@ -3058,6 +3071,44 @@ def create_member_role(member, abilities = member_role_abilities)
       let(:allowed_abilities) { [:admin_group_member] }
 
       it_behaves_like 'custom roles abilities'
+
+      context 'admin_service_account_member' do
+        let_it_be(:guest) { create(:user) }
+        let_it_be(:group) { create(:group) }
+
+        let_it_be(:group_member_guest) do
+          create(
+            :group_member,
+            user: guest,
+            source: group,
+            access_level: Gitlab::Access::GUEST
+          )
+        end
+
+        let(:role) do
+          create(
+            :member_role,
+            :guest,
+            namespace: group,
+            admin_group_member: true
+          )
+        end
+
+        before do
+          role.members << group_member_guest
+          stub_licensed_features(custom_roles: true)
+        end
+
+        it { is_expected.to be_disallowed(:admin_service_account_member) }
+
+        context 'when service accounts feature enabled' do
+          before do
+            stub_licensed_features(custom_roles: true, service_accounts: true)
+          end
+
+          it { is_expected.to be_allowed(:admin_service_account_member) }
+        end
+      end
     end
 
     context 'for a member role with manage_group_access_tokens true' do
diff --git a/ee/spec/requests/api/members_spec.rb b/ee/spec/requests/api/members_spec.rb
index 430734f85023..c6e54f918fa3 100644
--- a/ee/spec/requests/api/members_spec.rb
+++ b/ee/spec/requests/api/members_spec.rb
@@ -1273,6 +1273,68 @@
 
         expect(response).to have_gitlab_http_status(:forbidden)
       end
+
+      context 'when user to be added is a service account' do
+        let_it_be(:service_account) { create(:service_account) }
+
+        it 'does not allow adding the account to the group' do
+          post api("/groups/#{group.id}/members", owner),
+               params: { user_id: service_account.id, access_level: Member::DEVELOPER }
+
+          expect(response).to have_gitlab_http_status(:forbidden)
+        end
+
+        context 'and the service_account feature is enabled' do
+          before do
+            stub_licensed_features(service_accounts: true)
+          end
+
+          it 'adds the service account to the group' do
+            expect do
+              post api("/groups/#{group.id}/members", owner),
+                   params: { user_id: service_account.id, access_level: Member::DEVELOPER }
+            end.to change { Member.count }.by(1)
+          end
+        end
+      end
+    end
+
+    describe 'DELETE /groups/:id/members/:user_id' do
+      let(:service_account) { create(:user, :service_account) }
+      let!(:service_account_member) do
+        create(:group_member, :developer, group: group, user: service_account, ldap: false)
+      end
+
+      it 'fails for LDAP-managed group member' do
+        expect do
+          delete api("/groups/#{group.id}/members/#{ldap_developer.id}", owner)
+        end.not_to change { Member.count }
+
+        expect(response).to have_gitlab_http_status(:forbidden)
+      end
+
+      it 'fails when group member is a service account' do
+        expect do
+          delete api("/groups/#{group.id}/members/#{service_account.id}", owner)
+        end.not_to change { Member.count }
+
+        expect(response).to have_gitlab_http_status(:forbidden)
+      end
+
+      context 'when service_accounts feature is enabled' do
+        before do
+          stub_licensed_features(service_accounts: true)
+        end
+
+        it 'succeeds when group member is a service account' do
+          expect do
+            delete api("/groups/#{group.id}/members/#{service_account.id}", owner)
+          end.to change { Member.count }.by(-1)
+
+          expect(response).to have_gitlab_http_status(:no_content)
+          expect(response.body).to be_empty
+        end
+      end
     end
 
     describe 'POST /groups/:id/members/:user_id/override' do
diff --git a/ee/spec/services/ee/members/destroy_service_spec.rb b/ee/spec/services/ee/members/destroy_service_spec.rb
index eb52e8f1a77e..082144c4c82f 100644
--- a/ee/spec/services/ee/members/destroy_service_spec.rb
+++ b/ee/spec/services/ee/members/destroy_service_spec.rb
@@ -319,6 +319,26 @@
         end
       end
     end
+
+    context 'when removing a service account group member' do
+      subject(:destroy_service) { described_class.new(current_user).execute(member) }
+
+      let(:member_user) { create(:user, :service_account) }
+
+      it 'raises AccessDeniedError when :service_accounts feature unavailable' do
+        expect { subject }.to raise_error(Gitlab::Access::AccessDeniedError)
+      end
+
+      context 'when :service_accounts feature is enabled' do
+        before do
+          stub_licensed_features(service_accounts: true)
+        end
+
+        it 'removes the service account member' do
+          expect { subject }.to change { member.source.members_and_requesters.count }.by(-1)
+        end
+      end
+    end
   end
 
   context 'when current user is not present' do # ie, when the system initiates the destroy
diff --git a/ee/spec/services/ee/members/groups/creator_service_spec.rb b/ee/spec/services/ee/members/groups/creator_service_spec.rb
index cc6cd90c1d4e..fd02ee9f873d 100644
--- a/ee/spec/services/ee/members/groups/creator_service_spec.rb
+++ b/ee/spec/services/ee/members/groups/creator_service_spec.rb
@@ -200,5 +200,27 @@
         end
       end
     end
+
+    context 'when adding a service_account member' do
+      let_it_be(:user) { create(:user, :service_account) }
+      let_it_be(:source) { create(:group) }
+      let_it_be(:group_owner) { create(:user) }
+
+      before_all do
+        source.add_owner(group_owner)
+      end
+
+      before do
+        allow(group_owner).to receive(:can?).and_return(false)
+        allow(group_owner).to receive(:can?).with(:admin_service_account_member, anything).and_return(true)
+      end
+
+      it 'checks the appropriate permission' do
+        member = described_class.add_member(source, user, :maintainer, current_user: group_owner)
+
+        expect(member).to be_a GroupMember
+        expect(member).to be_persisted
+      end
+    end
   end
 end
diff --git a/lib/api/helpers/members_helpers.rb b/lib/api/helpers/members_helpers.rb
index 1a23dcd0d3c0..6d1cd9d8cd9d 100644
--- a/lib/api/helpers/members_helpers.rb
+++ b/lib/api/helpers/members_helpers.rb
@@ -85,20 +85,30 @@ def add_single_member_by_user_id(create_service_params)
         user_id = create_service_params[:user_id]
         user = User.find_by(id: user_id) # rubocop: disable CodeReuse/ActiveRecord
 
-        if user
-          conflict!('Member already exists') if member_already_exists?(source, user_id)
+        not_found!('User') unless user
 
-          instance = ::Members::CreateService.new(current_user, create_service_params)
-          instance.execute
+        conflict!('Member already exists') if member_already_exists?(source, user_id)
 
-          not_allowed! if instance.membership_locked # This currently can only be reached in EE if group membership is locked
+        instance = ::Members::CreateService.new(current_user, create_service_params)
+        result = instance.execute
 
-          member = instance.single_member
-          render_validation_error!(member) if member.invalid?
+        # This currently can only be reached in EE if group membership is locked
+        not_allowed! if instance.membership_locked
 
-          present_members(member)
+        if result[:status] == :error && result[:http_status] == :unauthorized
+          raise Gitlab::Access::AccessDeniedError
+        end
+
+        # prefer responding with model validations, if present
+        member = instance.single_member
+        render_validation_error!(member) if member.invalid?
+
+        # if errors occurred besides model validations or authorization failures,
+        # render those appropriately
+        if result[:status] == :error
+          render_structured_api_error!(result, :bad_request)
         else
-          not_found!('User')
+          present_members(member)
         end
       end
 
diff --git a/lib/api/members.rb b/lib/api/members.rb
index 908733d4aa12..e4bd29640cd6 100644
--- a/lib/api/members.rb
+++ b/lib/api/members.rb
@@ -119,8 +119,6 @@ class Members < ::API::Base
         post ":id/members", feature_category: feature_category do
           source = find_source(source_type, params[:id])
 
-          authorize_admin_source_member!(source_type, source)
-
           create_service_params = params.merge(source: source)
 
           if add_multiple_members?(params[:user_id].to_s)
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index 7fc58140fb6b..01a8ca055a5a 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -346,6 +346,24 @@
           expect(json_response['access_level']).to eq(Member::DEVELOPER)
         end
 
+        it 'returns the error message if there was an error adding the member to the group' do
+          error_message = 'Test CreateService Error Message'
+          allow_next_instance_of(::Members::CreateService) do |service|
+            expect(service).to receive(:execute).and_return(status: :error, message: error_message)
+            allow(service).to receive(:single_member).and_return(
+              instance_double(Member, invalid?: false)
+            )
+          end
+
+          expect do
+            post api("/#{source_type.pluralize}/#{source.id}/members", maintainer),
+                 params: { user_id: stranger.id, access_level: Member::DEVELOPER }
+          end.not_to change { source.members.count }
+          expect(response).to have_gitlab_http_status(:bad_request)
+          expect(json_response['status']).to eq('error')
+          expect(json_response['message']).to eq(error_message)
+        end
+
         context 'with invite_source considerations', :snowplow do
           let(:params) { { user_id: stranger.id, access_level: Member::DEVELOPER } }
 
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index c08b40e9528d..f019e7f40466 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -31,8 +31,10 @@
   context 'when the current user does not have permission to create members' do
     let(:current_user) { create(:user) }
 
-    it 'raises a Gitlab::Access::AccessDeniedError' do
-      expect { execute_service }.to raise_error(Gitlab::Access::AccessDeniedError)
+    it 'returns an unauthorized http_status' do
+      expect(execute_service[:status]).to eq(:error)
+      # this is expected by API::Helpers::MembersHelpers#add_single_member_by_user_id
+      expect(execute_service[:http_status]).to eq(:unauthorized)
     end
 
     context 'when a project maintainer attempts to add owners' do
@@ -56,6 +58,15 @@
     end
   end
 
+  context 'when trying to create a Membership with invalid params' do
+    let(:additional_params) { Hash[invite_source: '_invite_source_', expires_at: 3.days.ago] }
+
+    it 'returns an error response' do
+      expect(execute_service[:status]).to eq(:error)
+      expect(execute_service[:http_status]).to be_nil
+    end
+  end
+
   context 'when passing valid parameters' do
     it 'adds a user to members' do
       expect(execute_service[:status]).to eq(:success)
@@ -251,6 +262,7 @@
 
       it 'does not update the member' do
         expect(execute_service[:status]).to eq(:error)
+        expect(execute_service[:http_status]).to eq(:unauthorized)
         expect(execute_service[:message]).to eq("#{project_bot.username}: not authorized to update member")
         expect(Onboarding::Progress.completed?(source.namespace, :user_added)).to be(false)
       end
diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb
index 01d6642e814f..43b60fad67d6 100644
--- a/spec/support/shared_examples/models/member_shared_examples.rb
+++ b/spec/support/shared_examples/models/member_shared_examples.rb
@@ -139,6 +139,7 @@
         expect(source.reload).to have_user(project_bot)
         expect(member).to be_persisted
         expect(member.access_level).to eq(Gitlab::Access::DEVELOPER)
+        expect(member.errors.added?(:base, :unauthorized)).to eq(true)
         expect(member.errors.full_messages).to include(/not authorized to update member/)
       end
     end
@@ -169,6 +170,7 @@
 
       expect(member).not_to be_persisted
       expect(source).not_to have_user(user)
+      expect(member.errors.added?(:base, :unauthorized)).to eq(true)
       expect(member.errors.full_messages).to include(/not authorized to create member/)
     end
   end
diff --git a/spec/support/shared_examples/requests/api/members_shared_examples.rb b/spec/support/shared_examples/requests/api/members_shared_examples.rb
index 9136f60eb934..135a984f8f9c 100644
--- a/spec/support/shared_examples/requests/api/members_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/members_shared_examples.rb
@@ -14,7 +14,7 @@
 
 RSpec.shared_examples 'a 403 response when user does not have rights to manage members of a specific access level' do
   it 'returns 403' do
-    route
+    expect { route }.not_to change { Member.count }
 
     expect(response).to have_gitlab_http_status(:forbidden)
   end
-- 
GitLab