Commit 2184b87b authored by Max Woolf's avatar Max Woolf

Add group <> project level MR approval settings cascade

Adds a new API endpoint to be the canonical list of
MR approval settings for a project, which inherits
from the instance and group. It also contains
information about which level defined the rule.

Adds logic to the Resovler class to calculate.

Adds specs for every concievable input.
parent 6c981091
......@@ -32,6 +32,10 @@ module EE
::Gitlab::CurrentSettings.disable_overriding_approvers_per_merge_request
end
condition(:group_merge_request_approval_settings_enabled) do
@subject.feature_available?(:group_merge_request_approval_settings)
end
with_scope :global
condition(:locked_merge_request_author_setting) do
License.feature_available?(:admin_merge_request_approvers_rules) &&
......@@ -387,6 +391,10 @@ module EE
end
rule { auditor | can?(:developer_access) }.enable :add_project_to_instance_security_dashboard
rule { (admin | owner) & group_merge_request_approval_settings_enabled }.policy do
enable :admin_merge_request_approval_settings
end
end
override :lookup_access_level!
......
......@@ -5,13 +5,30 @@ module MergeRequestApprovalSettings
def execute
return ServiceResponse.error(message: 'Insufficient permissions') unless allowed?
setting = GroupMergeRequestApprovalSetting.find_or_initialize_by_group(container)
setting.assign_attributes(params)
if container.is_a?(Group)
setting = GroupMergeRequestApprovalSetting.find_or_initialize_by_group(container)
setting.assign_attributes(params)
if setting.save
ServiceResponse.success(payload: setting)
else
ServiceResponse.error(message: setting.errors.messages)
if setting.save
ServiceResponse.success(payload: setting)
else
ServiceResponse.error(message: setting.errors.messages)
end
elsif container.is_a?(Project)
resolved_params = {
merge_requests_author_approval: params[:allow_author_approval],
merge_requests_disable_committers_approval: !params[:allow_committer_approval],
disable_overriding_approvers_per_merge_request: !params[:allow_overrides_to_approver_list_per_merge_request],
reset_approvals_on_push: !params[:retain_approvals_on_push],
require_password_to_approve: params[:require_password_to_approve]
}
result = ::Projects::UpdateService.new(container, current_user, resolved_params).execute
if result[:status] == :success
ServiceResponse.success(payload: container)
else
ServiceResponse.error(message: container.errors.messages)
end
end
end
......
......@@ -2,7 +2,7 @@
module API
module Entities
class GroupMergeRequestApprovalSetting < Grape::Entity
class MergeRequestApprovalSetting < Grape::Entity
expose :allow_author_approval
expose :allow_committer_approval
expose :allow_overrides_to_approver_list_per_merge_request
......
# frozen_string_literal: true
module API
class GroupMergeRequestApprovalSettings < ::API::Base
feature_category :source_code_management
before do
authenticate!
not_found! unless ::Feature.enabled?(:group_merge_request_approval_settings_feature_flag, user_group)
authorize! :admin_merge_request_approval_settings, user_group
end
params do
requires :id, type: String, desc: 'The ID of a group'
end
resource :groups, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
segment ':id/merge_request_approval_setting' do
desc 'Get group merge request approval setting' do
detail 'This feature is gated by the :group_merge_request_approval_settings_feature_flag'
success ::API::Entities::GroupMergeRequestApprovalSetting
end
get do
setting = ComplianceManagement::MergeRequestApprovalSettings::Resolver.new(user_group).execute
present setting, with: ::API::Entities::GroupMergeRequestApprovalSetting
end
desc 'Update existing merge request approval setting' do
detail 'This feature is gated by the :group_merge_request_approval_settings_feature_flag'
success ::API::Entities::GroupMergeRequestApprovalSetting
end
params do
optional :allow_author_approval, type: Boolean, desc: 'Allow authors to self-approve merge requests'
optional :allow_committer_approval, type: Boolean, desc: 'Allow committers to approve merge requests'
optional :allow_overrides_to_approver_list_per_merge_request,
type: Boolean, desc: 'Allow overrides to approver list per merge request'
optional :retain_approvals_on_push, type: Boolean, desc: 'Retain approval count on a new push'
optional :require_password_to_approve,
type: Boolean, desc: 'Require approver to authenticate before approving'
at_least_one_of :allow_author_approval,
:allow_committer_approval,
:allow_overrides_to_approver_list_per_merge_request,
:retain_approvals_on_push,
:require_password_to_approve
end
put do
setting_params = declared_params(include_missing: false)
response = ::MergeRequestApprovalSettings::UpdateService
.new(container: user_group, current_user: current_user, params: setting_params).execute
if response.success?
setting = ComplianceManagement::MergeRequestApprovalSettings::Resolver.new(user_group).execute
present setting, with: ::API::Entities::GroupMergeRequestApprovalSetting
else
render_api_error!(response.message, :bad_request)
end
end
end
end
end
end
# frozen_string_literal: true
module API
class MergeRequestApprovalSettings < ::API::Base
feature_category :source_code_management
before do
authenticate!
end
helpers do
params :merge_request_approval_settings do
optional :allow_author_approval, type: Boolean, desc: 'Allow authors to self-approve merge requests', allow_blank: false
optional :allow_committer_approval, type: Boolean, desc: 'Allow committers to approve merge requests', allow_blank: false
optional :allow_overrides_to_approver_list_per_merge_request,
type: Boolean, desc: 'Allow overrides to approver list per merge request', allow_blank: false
optional :retain_approvals_on_push, type: Boolean, desc: 'Retain approval count on a new push', allow_blank: false
optional :require_password_to_approve,
type: Boolean, desc: 'Require approver to authenticate before approving', allow_blank: false
at_least_one_of :allow_author_approval,
:allow_committer_approval,
:allow_overrides_to_approver_list_per_merge_request,
:retain_approvals_on_push,
:require_password_to_approve
end
end
params do
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before do
authorize! :admin_merge_request_approval_settings, user_project
end
segment ':id/merge_request_approval_setting' do
desc 'Get project-level MR approval settings' do
detail 'This feature was introduced in 14.3 behind the :group_merge_request_approval_settings_feature_flag'
success EE::API::Entities::MergeRequestApprovalSettings
end
get do
not_found! unless ::Feature.enabled?(:group_merge_request_approval_settings_feature_flag, user_project.root_ancestor)
group = user_project.group.present? ? user_project.root_ancestor : nil
setting = ComplianceManagement::MergeRequestApprovalSettings::Resolver.new(group, project: user_project).execute
present setting, with: ::API::Entities::MergeRequestApprovalSetting
end
desc 'Update existing merge request approval setting' do
detail 'This feature is gated by the :group_merge_request_approval_settings_feature_flag'
success ::API::Entities::MergeRequestApprovalSetting
end
params do
use :merge_request_approval_settings
end
put do
setting_params = declared_params(include_missing: false)
response = ::MergeRequestApprovalSettings::UpdateService
.new(container: user_project, current_user: current_user, params: setting_params).execute
if response.success?
group = user_project.group.present? ? user_project.root_ancestor : nil
setting = ComplianceManagement::MergeRequestApprovalSettings::Resolver.new(group, project: user_project).execute
present setting, with: ::API::Entities::MergeRequestApprovalSetting
else
render_api_error!(response.message, :bad_request)
end
end
end
end
params do
requires :id, type: String, desc: 'The ID of a group'
end
resource :groups, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before do
not_found! unless ::Feature.enabled?(:group_merge_request_approval_settings_feature_flag, user_group)
authorize! :admin_merge_request_approval_settings, user_group
end
segment ':id/merge_request_approval_setting' do
desc 'Get group merge request approval setting' do
detail 'This feature is gated by the :group_merge_request_approval_settings_feature_flag'
success ::API::Entities::MergeRequestApprovalSetting
end
get do
setting = ComplianceManagement::MergeRequestApprovalSettings::Resolver.new(user_group).execute
present setting, with: ::API::Entities::MergeRequestApprovalSetting
end
desc 'Update existing merge request approval setting' do
detail 'This feature is gated by the :group_merge_request_approval_settings_feature_flag'
success ::API::Entities::MergeRequestApprovalSetting
end
params do
use :merge_request_approval_settings
end
put do
setting_params = declared_params(include_missing: false)
response = ::MergeRequestApprovalSettings::UpdateService
.new(container: user_group, current_user: current_user, params: setting_params).execute
if response.success?
setting = ComplianceManagement::MergeRequestApprovalSettings::Resolver.new(user_group).execute
present setting, with: ::API::Entities::MergeRequestApprovalSetting
else
render_api_error!(response.message, :bad_request)
end
end
end
end
end
end
......@@ -2,8 +2,9 @@
module ComplianceManagement
module MergeRequestApprovalSettings
class Resolver
def initialize(group)
def initialize(group, project: nil)
@group = group
@project = project
end
def execute
......@@ -17,38 +18,45 @@ module ComplianceManagement
end
def allow_author_approval
instance_value = instance_settings.current_application_settings.prevent_merge_requests_author_approval
instance_value = !instance_settings.prevent_merge_requests_author_approval
group_value = group_settings&.allow_author_approval
project_value = @project&.merge_requests_author_approval
setting(:allow_author_approval, instance_value, group_value, default: true)
setting(instance_value, group_value, project_value)
end
def allow_committer_approval
instance_value = instance_settings.prevent_merge_requests_committers_approval
instance_value = !instance_settings.prevent_merge_requests_committers_approval
group_value = group_settings&.allow_committer_approval
project_value = @project ? !@project.merge_requests_disable_committers_approval : nil
setting(:allow_committer_approval, instance_value, group_value, default: true)
setting(instance_value, group_value, project_value)
end
def allow_overrides_to_approver_list_per_merge_request
instance_value = instance_settings.disable_overriding_approvers_per_merge_request
instance_value = !instance_settings.disable_overriding_approvers_per_merge_request
group_value = group_settings&.allow_overrides_to_approver_list_per_merge_request
project_value = @project ? !@project.disable_overriding_approvers_per_merge_request : nil
setting(:allow_overrides_to_approver_list_per_merge_request, instance_value, group_value, default: true)
setting(instance_value, group_value, project_value)
end
def retain_approvals_on_push
instance_value = nil
group_value = group_settings&.retain_approvals_on_push
project_value = @project ? !@project.reset_approvals_on_push : nil
setting(:retain_approvals_on_push, instance_value, group_value, default: true)
setting(nil, group_value, project_value)
end
def require_password_to_approve
instance_value = nil
group_value = group_settings&.require_password_to_approve
project_value = @project&.require_password_to_approve
setting(:require_password_to_approve, instance_value, group_value, default: false)
ComplianceManagement::MergeRequestApprovalSettings::Setting.new(
value: require_password_value(group_value, project_value),
locked: require_password_locked?(group_value, false),
inherited_from: (:group if require_password_locked?(group_value, false))
)
end
private
......@@ -61,16 +69,39 @@ module ComplianceManagement
@group&.group_merge_request_approval_setting
end
def setting(name, instance_value, group_value, default:)
# Setting value should be the instance-level, if set.
# Otherwise group-level.
# Finally, fallback on to the default defined here.
value = instance_value ? !instance_value : group_value
def value(instance_value, group_value, project_value)
[instance_value, group_value, project_value].compact.all?
end
def require_password_value(group_value, project_value)
[group_value, project_value].any?
end
def locked?(instance_value, group_value, project_value)
_, *inherited = [project_value, group_value, instance_value].compact
inherited.any? { |value| value == false }
end
def require_password_locked?(group_value, default)
return false if @project.nil?
return false if @group.nil?
group_value || default
end
def inherited_from(instance_value, group_value, project_value)
return :instance if instance_value == false
return :group if group_value == false && !project_value.nil?
nil
end
def setting(instance_value, group_value, project_value)
ComplianceManagement::MergeRequestApprovalSettings::Setting.new(
value: value.nil? ? default : value,
locked: instance_value.nil? ? false : instance_value,
inherited_from: instance_value ? :instance : nil
value: value(instance_value, group_value, project_value),
locked: locked?(instance_value, group_value, project_value),
inherited_from: inherited_from(instance_value, group_value, project_value)
)
end
end
......
......@@ -30,7 +30,7 @@ module EE
mount ::API::GroupPushRule
mount ::API::MergeTrains
mount ::API::GroupHooks
mount ::API::GroupMergeRequestApprovalSettings
mount ::API::MergeRequestApprovalSettings
mount ::API::Scim
mount ::API::ManagedLicenses
mount ::API::ProjectApprovals
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe API::Entities::GroupMergeRequestApprovalSetting do
RSpec.describe API::Entities::MergeRequestApprovalSetting do
let(:group_merge_request_approval_setting) { build(:group_merge_request_approval_setting) }
subject { described_class.new(group_merge_request_approval_setting).as_json }
......
......@@ -1404,6 +1404,35 @@ RSpec.describe ProjectPolicy do
end
end
describe ':admin_merge_request_approval_settings' do
let(:project) { private_project }
using RSpec::Parameterized::TableSyntax
where(:role, :licensed, :allowed) do
:guest | true | false
:reporter | true | false
:developer | true | false
:maintainer | false | false
:maintainer | true | false
:owner | false | false
:owner | true | true
:admin | true | true
:admin | false | false
end
with_them do
let(:current_user) { public_send(role) }
before do
stub_licensed_features(group_merge_request_approval_settings: licensed)
enable_admin_mode!(current_user) if role == :admin
end
it { is_expected.to(allowed ? be_allowed(:admin_merge_request_approval_settings) : be_disallowed(:admin_merge_request_approval_settings)) }
end
end
describe ':modify_approvers_rules' do
it_behaves_like 'merge request approval settings' do
let(:app_setting) { :disable_overriding_approvers_per_merge_request }
......
......@@ -2,13 +2,12 @@
require 'spec_helper'
RSpec.describe API::GroupMergeRequestApprovalSettings do
RSpec.describe API::MergeRequestApprovalSettings do
let_it_be_with_reload(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let_it_be_with_reload(:project) { create(:project, group: group) }
let_it_be(:setting) { create(:group_merge_request_approval_setting, group: group) }
let(:url) { "/groups/#{group.id}/merge_request_approval_setting" }
shared_examples "resolvable" do
using RSpec::Parameterized::TableSyntax
......@@ -44,6 +43,8 @@ RSpec.describe API::GroupMergeRequestApprovalSettings do
end
describe 'GET /groups/:id/merge_request_approval_settings' do
let(:url) { "/groups/#{group.id}/merge_request_approval_setting" }
context 'when feature flag is disabled' do
before do
stub_feature_flags(group_merge_request_approval_settings_feature_flag: false)
......@@ -131,6 +132,7 @@ RSpec.describe API::GroupMergeRequestApprovalSettings do
end
describe 'PUT /groups/:id/merge_request_approval_setting' do
let(:url) { "/groups/#{group.id}/merge_request_approval_setting" }
let(:params) { { allow_author_approval: true } }
context 'when feature flag is disabled' do
......@@ -172,13 +174,13 @@ RSpec.describe API::GroupMergeRequestApprovalSettings do
end
context 'when update fails' do
let(:params) { { allow_author_approval: nil } }
let(:params) { { allow_author_approval: 45 } }
it 'returns 400 status', :aggregate_failures do
put api(url, user), params: params
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']).to eq('allow_author_approval' => ['must be a boolean value'])
expect(json_response['error']).to match(/allow_.*_approval is invalid/)
end
end
......@@ -209,4 +211,99 @@ RSpec.describe API::GroupMergeRequestApprovalSettings do
end
end
end
describe 'GET /projects/:id/merge_request_approval_settings' do
let(:url) { "/projects/#{project.id}/merge_request_approval_setting" }
context 'when feature flag is disabled' do
before do
stub_feature_flags(group_merge_request_approval_settings_feature_flag: false)
end
it 'returns 404 status' do
get api(url, user)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when feature flag is enabled' do
before do
group.add_owner(user)
allow(Ability).to receive(:allowed?).and_call_original
stub_feature_flags(group_merge_request_approval_settings_feature_flag: true)
stub_licensed_features(group_merge_request_approval_settings: true)
allow(Ability).to receive(:allowed?)
.with(user, :admin_merge_request_approval_settings, project)
.and_return(true)
end
it 'matches the response schema' do
get api(url, user)
expect(response).to match_response_schema('public_api/v4/group_merge_request_approval_settings', dir: 'ee')
end
context 'when the project does not have existing settings' do
before do
project.update!(
merge_requests_author_approval: nil,
merge_requests_disable_committers_approval: nil,
disable_overriding_approvers_per_merge_request: nil,
reset_approvals_on_push: nil,
require_password_to_approve: nil
)
end
it 'returns in-memory default settings', :aggregate_failures do
get api(url, user)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['allow_author_approval']['value']).to eq(false)
expect(json_response['allow_committer_approval']['value']).to eq(false)
expect(json_response['allow_overrides_to_approver_list_per_merge_request']['value']).to eq(false)
expect(json_response['retain_approvals_on_push']['value']).to eq(false)
expect(json_response['require_password_to_approve']['value']).to eq(false)
end
end
end
end
describe 'PUT /projects/:id/merge_request_approval_settings' do
let(:url) { "/projects/#{project.id}/merge_request_approval_setting" }
context 'when feature flag is disabled' do
before do
stub_feature_flags(group_merge_request_approval_settings_feature_flag: false)
end
it 'returns 404 status' do
put api(url, user)
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when feature flag is enabled' do
let(:params) { { retain_approvals_on_push: false } }
before do
group.add_owner(user)
allow(Ability).to receive(:allowed?).and_call_original
stub_feature_flags(group_merge_request_approval_settings_feature_flag: true)
stub_licensed_features(group_merge_request_approval_settings: true)
allow(Ability).to receive(:allowed?)
.with(user, :admin_merge_request_approval_settings, project)
.and_return(true)
end
it 'matches the response schema and updates the params' do
put api(url, user), params: params
expect(project.reset_approvals_on_push).to be true
expect(response).to match_response_schema('public_api/v4/group_merge_request_approval_settings', dir: 'ee')
end
end
end
end
......@@ -3,20 +3,58 @@
require 'spec_helper'
RSpec.describe MergeRequestApprovalSettings::UpdateService do
let_it_be_with_reload(:group) { create(:group) }
let!(:group) { create(:group) }
let!(:project) { create(:project, merge_requests_author_approval: true) }
let_it_be(:user) { create(:user) }
let(:params) { { allow_author_approval: true } }
let(:params) { { allow_author_approval: false } }
subject(:service) do
described_class.new(
container: group,
container: container,
current_user: user,
params: params
)
end
describe 'execute' do
describe 'execute with a Project as container' do
let(:container) { project }
context 'user does not have permissions' do
before do
allow(service).to receive(:can?).with(user, :admin_merge_request_approval_settings, container).and_return(false)
end
it 'responds with an error response', :aggregate_failures do
response = subject.execute
expect(response.status).to eq(:error)
expect(response.message).to eq('Insufficient permissions')
end
it 'does not change any of the approval settings' do
expect { subject.execute }.not_to change { project.attributes }
end
end
context 'user has permissions' do
before do
allow(service).to receive(:can?).with(user, :admin_merge_request_approval_settings, container).and_return(true)
end
it 'responds with a successful service response', :aggregate_failures do
response = subject.execute
expect(response).to be_success
expect(response.payload.reload.merge_requests_author_approval).to be(false)
expect(project.reload.merge_requests_author_approval).to be(false)
end
end
end
describe 'execute with a Group as container' do
let(:container) { group }
context 'user does not have permissions' do
before do
allow(service).to receive(:can?).with(user, :admin_merge_request_approval_settings, group).and_return(false)
......@@ -45,7 +83,7 @@ RSpec.describe MergeRequestApprovalSettings::UpdateService do
response = subject.execute
expect(response).to be_success
expect(response.payload.allow_author_approval).to be(true)
expect(response.payload.allow_author_approval).to be(false)
end
context 'when group has an existing setting' do
......@@ -53,16 +91,14 @@ RSpec.describe MergeRequestApprovalSettings::UpdateService do
let_it_be(:existing_setting) { create(:group_merge_request_approval_setting, group: group) }
it 'does not create a new setting' do
expect { subject.execute }
.to change { GroupMergeRequestApprovalSetting.count }.by(0)
.and change { existing_setting.reload.allow_author_approval }.to(true)
expect { subject.execute }.not_to change { GroupMergeRequestApprovalSetting.count }
end
it 'responds with a successful service response', :aggregate_failures do
response = subject.execute
expect(response).to be_success
expect(response.payload.allow_author_approval).to be(true)
expect(response.payload.allow_author_approval).to be(false)
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment