diff --git a/.rubocop_todo/gitlab/namespaced_class.yml b/.rubocop_todo/gitlab/namespaced_class.yml index ae600f6af323f13add507c9905ab5766803c8749..bfb406f37a4aead293e3bca34c458514ed7c90e4 100644 --- a/.rubocop_todo/gitlab/namespaced_class.yml +++ b/.rubocop_todo/gitlab/namespaced_class.yml @@ -404,6 +404,7 @@ Gitlab/NamespacedClass: - 'app/policies/project_hook_policy.rb' - 'app/policies/prometheus_alert_policy.rb' - 'app/policies/protected_branch_policy.rb' + - 'app/policies/protected_branch_access_policy.rb' - 'app/policies/release_policy.rb' - 'app/policies/repository_policy.rb' - 'app/policies/resource_label_event_policy.rb' diff --git a/app/graphql/types/branch_protections/base_access_level_type.rb b/app/graphql/types/branch_protections/base_access_level_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..2fd9e3942960841911f0d6ba5239fbd5767e1c10 --- /dev/null +++ b/app/graphql/types/branch_protections/base_access_level_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module BranchProtections + class BaseAccessLevelType < Types::BaseObject + authorize :read_protected_branch + + field :access_level, + type: GraphQL::Types::Int, + null: false, + description: 'GitLab::Access level.' + + field :access_level_description, + type: GraphQL::Types::String, + null: false, + description: 'Human readable representation for this access level.', + hash_key: 'humanize' + end + end +end diff --git a/app/graphql/types/branch_protections/merge_access_level_type.rb b/app/graphql/types/branch_protections/merge_access_level_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..85295e1ba25d5a9ea486b43903c762eb5b25ac4a --- /dev/null +++ b/app/graphql/types/branch_protections/merge_access_level_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module BranchProtections + class MergeAccessLevelType < BaseAccessLevelType # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'MergeAccessLevel' + description 'Represents the merge access level of a branch protection.' + accepts ::ProtectedBranch::MergeAccessLevel + end + end +end diff --git a/app/graphql/types/branch_rules/branch_protection_type.rb b/app/graphql/types/branch_rules/branch_protection_type.rb index b391eba58beb3e2aacc32c56f36a1b495d1107a6..02379dcf5582ff3ff6cad2f991bafd7268857b59 100644 --- a/app/graphql/types/branch_rules/branch_protection_type.rb +++ b/app/graphql/types/branch_rules/branch_protection_type.rb @@ -8,6 +8,11 @@ class BranchProtectionType < BaseObject accepts ::ProtectedBranch authorize :read_protected_branch + field :merge_access_levels, + type: Types::BranchProtections::MergeAccessLevelType.connection_type, + null: true, + description: 'Details about who can merge when this branch is the source branch.' + field :allow_force_push, type: GraphQL::Types::Boolean, null: false, diff --git a/app/policies/protected_branch_access_policy.rb b/app/policies/protected_branch_access_policy.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f33af89d2a43b4540ddd71ba8600c9a55494b45 --- /dev/null +++ b/app/policies/protected_branch_access_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ProtectedBranchAccessPolicy < BasePolicy + delegate { @subject.protected_branch } +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 25fd23ffddb0398887f36722850ba544dbc49fc7..ebe922239c9e5d8f1b94c0d162d921bb14cb18b1 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -7837,6 +7837,29 @@ The edge type for [`MemberInterface`](#memberinterface). | <a id="memberinterfaceedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="memberinterfaceedgenode"></a>`node` | [`MemberInterface`](#memberinterface) | The item at the end of the edge. | +#### `MergeAccessLevelConnection` + +The connection type for [`MergeAccessLevel`](#mergeaccesslevel). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mergeaccesslevelconnectionedges"></a>`edges` | [`[MergeAccessLevelEdge]`](#mergeaccessleveledge) | A list of edges. | +| <a id="mergeaccesslevelconnectionnodes"></a>`nodes` | [`[MergeAccessLevel]`](#mergeaccesslevel) | A list of nodes. | +| <a id="mergeaccesslevelconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `MergeAccessLevelEdge` + +The edge type for [`MergeAccessLevel`](#mergeaccesslevel). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mergeaccessleveledgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="mergeaccessleveledgenode"></a>`node` | [`MergeAccessLevel`](#mergeaccesslevel) | The item at the end of the edge. | + #### `MergeRequestAssigneeConnection` The connection type for [`MergeRequestAssignee`](#mergerequestassignee). @@ -10083,6 +10106,7 @@ Branch protection details for a branch rule. | Name | Type | Description | | ---- | ---- | ----------- | | <a id="branchprotectionallowforcepush"></a>`allowForcePush` | [`Boolean!`](#boolean) | Toggle force push to the branch for users with write access. | +| <a id="branchprotectionmergeaccesslevels"></a>`mergeAccessLevels` | [`MergeAccessLevelConnection`](#mergeaccesslevelconnection) | Details about who can merge when this branch is the source branch. (see [Connections](#connections)) | ### `BranchRule` @@ -13783,6 +13807,17 @@ Maven metadata. | <a id="mavenmetadatapath"></a>`path` | [`String!`](#string) | Path of the Maven package. | | <a id="mavenmetadataupdatedat"></a>`updatedAt` | [`Time!`](#time) | Date of most recent update. | +### `MergeAccessLevel` + +Represents the merge access level of a branch protection. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mergeaccesslevelaccesslevel"></a>`accessLevel` | [`Int!`](#int) | GitLab::Access level. | +| <a id="mergeaccesslevelaccessleveldescription"></a>`accessLevelDescription` | [`String!`](#string) | Human readable representation for this access level. | + ### `MergeRequest` #### Fields diff --git a/spec/factories/protected_branches.rb b/spec/factories/protected_branches.rb index cee7b57723611ce167b156471ca23c5ff49c5fe8..142ddb2b2c7548b7b506299a1decc4336e752671 100644 --- a/spec/factories/protected_branches.rb +++ b/spec/factories/protected_branches.rb @@ -17,6 +17,16 @@ ProtectedBranches::CacheService.new(protected_branch.project).refresh end + after(:build) do |protected_branch, evaluator| + if evaluator.default_access_level && evaluator.default_push_level + protected_branch.push_access_levels.new(access_level: Gitlab::Access::MAINTAINER) + end + + if evaluator.default_access_level && evaluator.default_merge_level + protected_branch.merge_access_levels.new(access_level: Gitlab::Access::MAINTAINER) + end + end + trait :create_branch_on_repository do association :project, factory: [:project, :repository] @@ -31,59 +41,63 @@ end end - trait :developers_can_push do + trait :maintainers_can_push do transient do default_push_level { false } end after(:build) do |protected_branch| - protected_branch.push_access_levels.new(access_level: Gitlab::Access::DEVELOPER) + protected_branch.push_access_levels.new(access_level: Gitlab::Access::MAINTAINER) end end - trait :developers_can_merge do + trait :maintainers_can_merge do transient do - default_merge_level { false } + default_push_level { false } end after(:build) do |protected_branch| - protected_branch.merge_access_levels.new(access_level: Gitlab::Access::DEVELOPER) + protected_branch.push_access_levels.new(access_level: Gitlab::Access::MAINTAINER) end end - trait :no_one_can_push do + trait :developers_can_push do transient do default_push_level { false } end after(:build) do |protected_branch| - protected_branch.push_access_levels.new(access_level: Gitlab::Access::NO_ACCESS) + protected_branch.push_access_levels.new(access_level: Gitlab::Access::DEVELOPER) end end - trait :maintainers_can_push do + trait :developers_can_merge do transient do - default_push_level { false } + default_merge_level { false } end after(:build) do |protected_branch| - protected_branch.push_access_levels.new(access_level: Gitlab::Access::MAINTAINER) + protected_branch.merge_access_levels.new(access_level: Gitlab::Access::DEVELOPER) end end - after(:build) do |protected_branch, evaluator| - if evaluator.default_access_level && evaluator.default_push_level - protected_branch.push_access_levels.new(access_level: Gitlab::Access::MAINTAINER) + trait :no_one_can_push do + transient do + default_push_level { false } end - if evaluator.default_access_level && evaluator.default_merge_level - protected_branch.merge_access_levels.new(access_level: Gitlab::Access::MAINTAINER) + after(:build) do |protected_branch| + protected_branch.push_access_levels.new(access_level: Gitlab::Access::NO_ACCESS) end end trait :no_one_can_merge do - after(:create) do |protected_branch| - protected_branch.merge_access_levels.first.update!(access_level: Gitlab::Access::NO_ACCESS) + transient do + default_merge_level { false } + end + + after(:build) do |protected_branch| + protected_branch.merge_access_levels.new(access_level: Gitlab::Access::NO_ACCESS) end end end diff --git a/spec/graphql/types/branch_protection_type_spec.rb b/spec/graphql/types/branch_protection_type_spec.rb index 6efa457227f954e101e2cfcde5752eda1074c211..5891df666c5e536a6811f4bc125132114d089020 100644 --- a/spec/graphql/types/branch_protection_type_spec.rb +++ b/spec/graphql/types/branch_protection_type_spec.rb @@ -5,7 +5,7 @@ RSpec.describe GitlabSchema.types['BranchProtection'] do subject { described_class } - let(:fields) { %i[allow_force_push] } + let(:fields) { %i[merge_access_levels allow_force_push] } specify { is_expected.to require_graphql_authorizations(:read_protected_branch) } diff --git a/spec/graphql/types/branch_protections/merge_access_level_type_spec.rb b/spec/graphql/types/branch_protections/merge_access_level_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0c9c8553b6f92e055c55e0934e0e4c10959a4532 --- /dev/null +++ b/spec/graphql/types/branch_protections/merge_access_level_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['MergeAccessLevel'] do + subject { described_class } + + let(:fields) { %i[access_level access_level_description] } + + specify { is_expected.to require_graphql_authorizations(:read_protected_branch) } + + specify { is_expected.to have_graphql_fields(fields) } +end diff --git a/spec/policies/protected_branch_access_policy_spec.rb b/spec/policies/protected_branch_access_policy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..68a130d666a7448d44b721e8a94d425e485af296 --- /dev/null +++ b/spec/policies/protected_branch_access_policy_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ProtectedBranchAccessPolicy do + let(:user) { create(:user) } + let(:protected_branch_access) { create(:protected_branch_merge_access_level) } + let(:project) { protected_branch_access.protected_branch.project } + + subject { described_class.new(user, protected_branch_access) } + + context 'as maintainers' do + before do + project.add_maintainer(user) + end + + it 'can be read' do + is_expected.to be_allowed(:read_protected_branch) + end + end + + context 'as guests' do + before do + project.add_guest(user) + end + + it 'can not be read' do + is_expected.to be_disallowed(:read_protected_branch) + end + end +end diff --git a/spec/requests/api/graphql/project/branch_protections/merge_access_levels_spec.rb b/spec/requests/api/graphql/project/branch_protections/merge_access_levels_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cb5006ec8e4d5d1b3c57d2f8098472bd4814d93f --- /dev/null +++ b/spec/requests/api/graphql/project/branch_protections/merge_access_levels_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting merge access levels for a branch protection' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + + let(:merge_access_level_data) { merge_access_levels_data[0] } + + let(:merge_access_levels_data) do + graphql_data_at('project', + 'branchRules', + 'nodes', + 0, + 'branchProtection', + 'mergeAccessLevels', + 'nodes') + end + + let(:project) { protected_branch.project } + + let(:merge_access_levels_count) { protected_branch.merge_access_levels.size } + + let(:variables) { { path: project.full_path } } + + let(:fields) { all_graphql_fields_for('MergeAccessLevel') } + + let(:query) do + <<~GQL + query($path: ID!) { + project(fullPath: $path) { + branchRules(first: 1) { + nodes { + branchProtection { + mergeAccessLevels { + nodes { + #{fields} + } + } + } + } + } + } + } + GQL + end + + context 'when the user does not have read_protected_branch abilities' do + let_it_be(:protected_branch) { create(:protected_branch) } + + before do + project.add_guest(current_user) + post_graphql(query, current_user: current_user, variables: variables) + end + + it_behaves_like 'a working graphql query' + + it { expect(merge_access_levels_data).not_to be_present } + end + + shared_examples 'merge access request' do + let(:merge_access) { protected_branch.merge_access_levels.first } + + before do + project.add_maintainer(current_user) + post_graphql(query, current_user: current_user, variables: variables) + end + + it_behaves_like 'a working graphql query' + + it 'returns all merge access levels' do + expect(merge_access_levels_data.size).to eq(merge_access_levels_count) + end + + it 'includes access_level' do + expect(merge_access_level_data['accessLevel']) + .to eq(merge_access.access_level) + end + + it 'includes access_level_description' do + expect(merge_access_level_data['accessLevelDescription']) + .to eq(merge_access.humanize) + end + end + + context 'when the user does have read_protected_branch abilities' do + let(:merge_access) { protected_branch.merge_access_levels.first } + + context 'when no one has access' do + let_it_be(:protected_branch) { create(:protected_branch, :no_one_can_merge) } + + it_behaves_like 'merge access request' + end + + context 'when developers have access' do + let_it_be(:protected_branch) { create(:protected_branch, :developers_can_merge) } + + it_behaves_like 'merge access request' + end + + context 'when maintainers have access' do + let_it_be(:protected_branch) { create(:protected_branch, :maintainers_can_merge) } + + it_behaves_like 'merge access request' + end + end +end