From 320195d1fc67a5576550618df41adb4965efbd51 Mon Sep 17 00:00:00 2001
From: Drew Blessing <drew@blessing.io>
Date: Thu, 2 Jul 2020 13:32:23 -0500
Subject: [PATCH] Prevent a project bot from being removed as member via the
 API

A project bot user is tied to a project access token. The user
should not be removed from the project directly. Instead, the
project access token should be removed, which will also remove
the member and user.
---
 app/policies/project_member_policy.rb         |  5 ++-
 app/services/members/destroy_service.rb       | 20 +++++++++--
 .../resource_access_tokens/revoke_service.rb  |  2 +-
 app/views/shared/members/_member.html.haml    |  4 +--
 .../dblessing-project-bot-prevent-removal.yml |  5 +++
 spec/policies/project_member_policy_spec.rb   | 33 +++++++++++++++++++
 spec/requests/api/members_spec.rb             | 13 ++++++++
 spec/services/members/destroy_service_spec.rb | 19 +++++++++++
 8 files changed, 95 insertions(+), 6 deletions(-)
 create mode 100644 changelogs/unreleased/dblessing-project-bot-prevent-removal.yml
 create mode 100644 spec/policies/project_member_policy_spec.rb

diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb
index f2f18406bd3..ca33b95e523 100644
--- a/app/policies/project_member_policy.rb
+++ b/app/policies/project_member_policy.rb
@@ -5,14 +5,17 @@ class ProjectMemberPolicy < BasePolicy
 
   condition(:target_is_owner, scope: :subject) { @subject.user == @subject.project.owner }
   condition(:target_is_self) { @user && @subject.user == @user }
+  condition(:project_bot) { @subject.user&.project_bot? }
 
   rule { anonymous }.prevent_all
   rule { target_is_owner }.prevent_all
 
-  rule { can?(:admin_project_member) }.policy do
+  rule { ~project_bot & can?(:admin_project_member) }.policy do
     enable :update_project_member
     enable :destroy_project_member
   end
 
+  rule { project_bot & can?(:admin_project_member) }.enable :destroy_project_bot_member
+
   rule { target_is_self }.enable :destroy_project_member
 end
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index 7e620047c78..fdd43260521 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -2,8 +2,8 @@
 
 module Members
   class DestroyService < Members::BaseService
-    def execute(member, skip_authorization: false, skip_subresources: false, unassign_issuables: false)
-      raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member)
+    def execute(member, skip_authorization: false, skip_subresources: false, unassign_issuables: false, destroy_bot: false)
+      raise Gitlab::Access::AccessDeniedError unless skip_authorization || authorized?(member, destroy_bot)
 
       @skip_auth = skip_authorization
 
@@ -28,6 +28,12 @@ module Members
 
     private
 
+    def authorized?(member, destroy_bot)
+      return can_destroy_bot_member?(member) if destroy_bot
+
+      can_destroy_member?(member)
+    end
+
     def delete_subresources(member)
       return unless member.is_a?(GroupMember) && member.user && member.group
 
@@ -55,6 +61,10 @@ module Members
       can?(current_user, destroy_member_permission(member), member)
     end
 
+    def can_destroy_bot_member?(member)
+      can?(current_user, destroy_bot_member_permission(member), member)
+    end
+
     def destroy_member_permission(member)
       case member
       when GroupMember
@@ -66,6 +76,12 @@ module Members
       end
     end
 
+    def destroy_bot_member_permission(member)
+      raise "Unsupported bot member type: #{member}" unless member.is_a?(ProjectMember)
+
+      :destroy_project_bot_member
+    end
+
     def enqueue_unassign_issuables(member)
       source_type = member.is_a?(GroupMember) ? 'Group' : 'Project'
 
diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb
index eea6bff572b..efeb0bfb8d5 100644
--- a/app/services/resource_access_tokens/revoke_service.rb
+++ b/app/services/resource_access_tokens/revoke_service.rb
@@ -35,7 +35,7 @@ module ResourceAccessTokens
     attr_reader :current_user, :access_token, :bot_user, :resource
 
     def remove_member
-      ::Members::DestroyService.new(current_user).execute(find_member)
+      ::Members::DestroyService.new(current_user).execute(find_member, destroy_bot: true)
     end
 
     def migrate_to_ghost_user
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index cc318ecded7..79dc3043e8d 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -67,7 +67,7 @@
                     class: 'btn btn-default align-self-center mr-sm-2',
                     title: _('Resend invite')
 
-        - if user != current_user && member.can_update? && !user&.project_bot?
+        - if user != current_user && member.can_update?
           = form_for member, remote: true, html: { class: "js-edit-member-form form-group #{'d-sm-flex' unless force_mobile_view}" } do |f|
             = f.hidden_field :access_level
             .member-form-control.dropdown{ class: [("mr-sm-2 d-sm-inline-block" unless force_mobile_view)] }
@@ -117,7 +117,7 @@
                       method: :delete,
                       data: { confirm: leave_confirmation_message(member.source) },
                       class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}"
-          - elsif !user&.project_bot?
+          - else
             %button{ data: { member_path: member_path(member.member), message: remove_member_message(member), is_access_request: member.request?.to_s, qa_selector: 'delete_member_button' },
               class: "js-remove-member-button btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}",
               title: remove_member_title(member) }
diff --git a/changelogs/unreleased/dblessing-project-bot-prevent-removal.yml b/changelogs/unreleased/dblessing-project-bot-prevent-removal.yml
new file mode 100644
index 00000000000..af29cee48ba
--- /dev/null
+++ b/changelogs/unreleased/dblessing-project-bot-prevent-removal.yml
@@ -0,0 +1,5 @@
+---
+title: Prevent a project bot from being removed as member
+merge_request: 35899
+author:
+type: added
diff --git a/spec/policies/project_member_policy_spec.rb b/spec/policies/project_member_policy_spec.rb
new file mode 100644
index 00000000000..ab8f8b83e7f
--- /dev/null
+++ b/spec/policies/project_member_policy_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ProjectMemberPolicy do
+  let(:project) { create(:project) }
+  let(:maintainer_user) { create(:user) }
+  let(:member) { create(:project_member, project: project, user: member_user) }
+
+  subject { described_class.new(maintainer_user, member) }
+
+  before do
+    create(:project_member, :maintainer, project: project, user: maintainer_user)
+  end
+
+  context 'with regular member' do
+    let(:member_user) { create(:user) }
+
+    it { is_expected.to be_allowed(:update_project_member) }
+    it { is_expected.to be_allowed(:destroy_project_member) }
+
+    it { is_expected.not_to be_allowed(:destroy_project_bot_member) }
+  end
+
+  context 'with a bot member' do
+    let(:member_user) { create(:user, :project_bot) }
+
+    it { is_expected.to be_allowed(:destroy_project_bot_member) }
+
+    it { is_expected.not_to be_allowed(:update_project_member) }
+    it { is_expected.not_to be_allowed(:destroy_project_member) }
+  end
+end
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index db910c6f097..bbce5b4cfb6 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -495,4 +495,17 @@ RSpec.describe API::Members do
       end.to change { project.members.count }.by(0)
     end
   end
+
+  context 'remove bot from project' do
+    it 'returns a 403 forbidden' do
+      project_bot = create(:user, :project_bot)
+      create(:project_member, project: project, user: project_bot)
+
+      expect do
+        delete api("/projects/#{project.id}/members/#{project_bot.id}", maintainer)
+
+        expect(response).to have_gitlab_http_status(:forbidden)
+      end.to change { project.members.count }.by(0)
+    end
+  end
 end
diff --git a/spec/services/members/destroy_service_spec.rb b/spec/services/members/destroy_service_spec.rb
index ce1077b1167..13e7b4c1006 100644
--- a/spec/services/members/destroy_service_spec.rb
+++ b/spec/services/members/destroy_service_spec.rb
@@ -153,6 +153,25 @@ RSpec.describe Members::DestroyService do
         end
       end
 
+      context 'with a project bot member' do
+        let(:member) { group_project.members.find_by(user_id: member_user.id) }
+        let(:member_user) { create(:user, :project_bot) }
+
+        before do
+          group_project.add_maintainer(member_user)
+        end
+
+        context 'when the destroy_bot flag is true' do
+          it_behaves_like 'a service destroying a member with access' do
+            let(:opts) { { destroy_bot: true } }
+          end
+        end
+
+        context 'when the destroy_bot flag is not specified' do
+          it_behaves_like 'a service raising Gitlab::Access::AccessDeniedError'
+        end
+      end
+
       context 'with a group member' do
         let(:member) { group.members.find_by(user_id: member_user.id) }
 
-- 
2.30.9