From 2f6931217be6375c62415ea9f6c0d90ffeec4d4a Mon Sep 17 00:00:00 2001 From: Dmytro Biryukov <dbiryukov@gitlab.com> Date: Wed, 7 Feb 2024 11:13:32 +0000 Subject: [PATCH] Allow adding groups to CI_JOB_TOKEN allowlist graphQL mutation Changelog: added --- .../job_token_scope/add_group_or_project.rb | 57 ++++ .../ci/job_token_scope/remove_group.rb | 49 ++++ app/graphql/types/ci/job_token_scope_type.rb | 6 + app/graphql/types/mutation_type.rb | 2 + app/models/ci/job_token/allowlist.rb | 12 + app/models/ci/job_token/scope.rb | 4 + .../add_group_or_project_service.rb | 22 ++ .../ci/job_token_scope/add_group_service.rb | 31 +++ .../job_token_scope/remove_group_service.rb | 28 ++ .../job_token_scope/edit_scope_validations.rb | 25 ++ doc/api/graphql/reference/index.md | 41 +++ rubocop/cop/graphql/id_type.rb | 2 +- .../add_group_or_project_spec.rb | 127 +++++++++ .../ci/job_token_scope/remove_group_spec.rb | 82 ++++++ .../ci/job_token_scope_resolver_spec.rb | 25 ++ .../types/ci/job_token_scope_type_spec.rb | 2 +- spec/models/ci/job_token/allowlist_spec.rb | 40 ++- spec/models/ci/job_token/scope_spec.rb | 24 +- .../add_group_or_project_spec.rb | 116 ++++++++ .../ci/job_token_scope/remove_group_spec.rb | 90 ++++++ .../add_group_or_project_service_spec.rb | 69 +++++ .../job_token_scope/add_group_service_spec.rb | 88 ++++++ .../remove_group_service_spec.rb | 97 +++++++ .../edit_scope_validations_spec.rb | 257 ++++++++++++++++++ .../models/ci/job_token_scope.rb | 2 +- ...t_group_job_token_scope_shared_examples.rb | 32 +++ 26 files changed, 1325 insertions(+), 5 deletions(-) create mode 100644 app/graphql/mutations/ci/job_token_scope/add_group_or_project.rb create mode 100644 app/graphql/mutations/ci/job_token_scope/remove_group.rb create mode 100644 app/services/ci/job_token_scope/add_group_or_project_service.rb create mode 100644 app/services/ci/job_token_scope/add_group_service.rb create mode 100644 app/services/ci/job_token_scope/remove_group_service.rb create mode 100644 spec/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb create mode 100644 spec/graphql/mutations/ci/job_token_scope/remove_group_spec.rb create mode 100644 spec/requests/api/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb create mode 100644 spec/requests/api/graphql/mutations/ci/job_token_scope/remove_group_spec.rb create mode 100644 spec/services/ci/job_token_scope/add_group_or_project_service_spec.rb create mode 100644 spec/services/ci/job_token_scope/add_group_service_spec.rb create mode 100644 spec/services/ci/job_token_scope/remove_group_service_spec.rb create mode 100644 spec/services/concerns/ci/job_token_scope/edit_scope_validations_spec.rb create mode 100644 spec/support/shared_examples/ci/edit_group_job_token_scope_shared_examples.rb diff --git a/app/graphql/mutations/ci/job_token_scope/add_group_or_project.rb b/app/graphql/mutations/ci/job_token_scope/add_group_or_project.rb new file mode 100644 index 0000000000000..1955b1101eba6 --- /dev/null +++ b/app/graphql/mutations/ci/job_token_scope/add_group_or_project.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module JobTokenScope + class AddGroupOrProject < BaseMutation + graphql_name 'CiJobTokenScopeAddGroupOrProject' + + include FindsProject + + authorize :admin_project + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Project that the CI job token scope belongs to.' + + argument :target_path, GraphQL::Types::ID, + required: true, + description: 'Group or project to be added to the CI job token scope.' + + field :ci_job_token_scope, + Types::Ci::JobTokenScopeType, + null: true, + description: "CI job token's access scope." + + def resolve(project_path:, target_path:) + project = authorized_find!(project_path) + + target = find_target_path(target_path) + + result = ::Ci::JobTokenScope::AddGroupOrProjectService + .new(project, current_user) + .execute(target) + + if result.success? + { + ci_job_token_scope: ::Ci::JobToken::Scope.new(project), + errors: [] + } + else + { + ci_job_token_scope: nil, + errors: [result.message] + } + end + end + + private + + def find_target_path(target_path) + ::Group.find_by_full_path(target_path) || + ::Project.find_by_full_path(target_path) + end + end + end + end +end diff --git a/app/graphql/mutations/ci/job_token_scope/remove_group.rb b/app/graphql/mutations/ci/job_token_scope/remove_group.rb new file mode 100644 index 0000000000000..40c654cd113c1 --- /dev/null +++ b/app/graphql/mutations/ci/job_token_scope/remove_group.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module JobTokenScope + class RemoveGroup < BaseMutation + graphql_name 'CiJobTokenScopeRemoveGroup' + + include FindsProject + + authorize :admin_project + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Project that the CI job token scope belongs to.' + + argument :target_group_path, GraphQL::Types::ID, + required: true, + description: 'Group to be removed from the CI job token scope.' + + field :ci_job_token_scope, + Types::Ci::JobTokenScopeType, + null: true, + description: "CI job token's access scope." + + def resolve(project_path:, target_group_path:) + project = authorized_find!(project_path) + target_group = Group.find_by_full_path(target_group_path) + + result = ::Ci::JobTokenScope::RemoveGroupService + .new(project, current_user) + .execute(target_group) + + if result.success? + { + ci_job_token_scope: ::Ci::JobToken::Scope.new(project), + errors: [] + } + else + { + ci_job_token_scope: nil, + errors: [result.message] + } + end + end + end + end + end +end diff --git a/app/graphql/types/ci/job_token_scope_type.rb b/app/graphql/types/ci/job_token_scope_type.rb index 639bbaa22afd3..6e91bc395e128 100644 --- a/app/graphql/types/ci/job_token_scope_type.rb +++ b/app/graphql/types/ci/job_token_scope_type.rb @@ -28,6 +28,12 @@ class JobTokenScopeType < BaseObject null: false, description: "Allow list of projects that can access the current project through its CI Job tokens.", method: :inbound_projects + + field :groups_allowlist, + Types::GroupType.connection_type, + null: false, + description: "Allow list of groups that can access the current project through its CI Job tokens.", + method: :groups end end # rubocop: enable Graphql/AuthorizeTypes diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 94bb3055bc4d8..8c3e5c6920586 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -152,9 +152,11 @@ class MutationType < BaseObject mount_mutation Mutations::Ci::Job::Retry mount_mutation Mutations::Ci::Job::ArtifactsDestroy mount_mutation Mutations::Ci::Job::Unschedule + mount_mutation Mutations::Ci::JobTokenScope::AddGroupOrProject mount_mutation Mutations::Ci::JobTokenScope::AddProject mount_mutation Mutations::Ci::JobArtifact::BulkDestroy, alpha: { milestone: '15.10' } mount_mutation Mutations::Ci::JobArtifact::Destroy + mount_mutation Mutations::Ci::JobTokenScope::RemoveGroup mount_mutation Mutations::Ci::JobTokenScope::RemoveProject mount_mutation Mutations::Ci::Pipeline::Cancel mount_mutation Mutations::Ci::Pipeline::Destroy diff --git a/app/models/ci/job_token/allowlist.rb b/app/models/ci/job_token/allowlist.rb index c4071ac32717e..5626624547d59 100644 --- a/app/models/ci/job_token/allowlist.rb +++ b/app/models/ci/job_token/allowlist.rb @@ -24,6 +24,10 @@ def projects Project.from_union(target_projects, remove_duplicates: false) end + def groups + ::Group.id_in(group_links.pluck(:target_group_id)) + end + def add!(target_project, user:) Ci::JobToken::ProjectScopeLink.create!( source_project: @source_project, @@ -33,6 +37,14 @@ def add!(target_project, user:) ) end + def add_group!(target_group, user:) + Ci::JobToken::GroupScopeLink.create!( + source_project: @source_project, + target_group: target_group, + added_by: user + ) + end + private def source_links diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index a512c9417ff29..35dcb6d7d592b 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -39,6 +39,10 @@ def inbound_projects inbound_allowlist.projects end + def groups + inbound_allowlist.groups + end + private def outbound_accessible?(accessed_project) diff --git a/app/services/ci/job_token_scope/add_group_or_project_service.rb b/app/services/ci/job_token_scope/add_group_or_project_service.rb new file mode 100644 index 0000000000000..5d4914d82c756 --- /dev/null +++ b/app/services/ci/job_token_scope/add_group_or_project_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ci + module JobTokenScope + class AddGroupOrProjectService < ::BaseService + include EditScopeValidations + + def execute(target) + validate_target_exists!(target) + + if target.is_a?(::Group) + ::Ci::JobTokenScope::AddGroupService.new(project, current_user).execute(target) + else + ::Ci::JobTokenScope::AddProjectService.new(project, current_user).execute(target) + end + + rescue EditScopeValidations::NotFoundError => e + ServiceResponse.error(message: e.message, reason: :not_found) + end + end + end +end diff --git a/app/services/ci/job_token_scope/add_group_service.rb b/app/services/ci/job_token_scope/add_group_service.rb new file mode 100644 index 0000000000000..6e83f8b9b6311 --- /dev/null +++ b/app/services/ci/job_token_scope/add_group_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ci + module JobTokenScope + class AddGroupService < ::BaseService + include EditScopeValidations + + def execute(target_group) + validate_group_add!(project, target_group, current_user) + + link = allowlist + .add_group!(target_group, user: current_user) + + ServiceResponse.success(payload: { group_link: link }) + + rescue ActiveRecord::RecordNotUnique + ServiceResponse.error(message: 'Target group is already in the job token scope') + rescue ActiveRecord::RecordInvalid => e + ServiceResponse.error(message: e.message) + rescue EditScopeValidations::ValidationError => e + ServiceResponse.error(message: e.message, reason: :insufficient_permissions) + end + + private + + def allowlist + Ci::JobToken::Allowlist.new(project) + end + end + end +end diff --git a/app/services/ci/job_token_scope/remove_group_service.rb b/app/services/ci/job_token_scope/remove_group_service.rb new file mode 100644 index 0000000000000..aef9742c7ffec --- /dev/null +++ b/app/services/ci/job_token_scope/remove_group_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Ci + module JobTokenScope + class RemoveGroupService < ::BaseService + include EditScopeValidations + + def execute(target_group) + validate_group_remove!(project, current_user) + + link = ::Ci::JobToken::GroupScopeLink + .for_source_and_target(project, target_group) + + return ServiceResponse.error(message: 'Target group is not in the job token scope') unless link + + if link.destroy + ServiceResponse.success + else + ServiceResponse.error(message: link.errors.full_messages.to_sentence, payload: { group_link: link }) + end + rescue EditScopeValidations::ValidationError => e + ServiceResponse.error(message: e.message, reason: :insufficient_permissions) + end + end + end +end + +Ci::JobTokenScope::RemoveGroupService.prepend_mod_with('Ci::JobTokenScope::RemoveGroupService') diff --git a/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb index 427aebf397e32..3fa3ce9812e0e 100644 --- a/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb +++ b/app/services/concerns/ci/job_token_scope/edit_scope_validations.rb @@ -4,10 +4,16 @@ module Ci module JobTokenScope module EditScopeValidations ValidationError = Class.new(StandardError) + NotFoundError = Class.new(StandardError) TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND = "The target_project that you are attempting to access does " \ "not exist or you don't have permission to perform this action" + TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND = "The target_group that you are attempting to access does " \ + "not exist or you don't have permission to perform this action" + + TARGET_DOES_NOT_EXIST = 'The target does not exists' + def validate_edit!(source_project, target_project, current_user) unless can?(current_user, :admin_project, source_project) raise ValidationError, "Insufficient permissions to modify the job token scope" @@ -17,6 +23,25 @@ def validate_edit!(source_project, target_project, current_user) raise ValidationError, TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND end end + + def validate_group_add!(source_project, target_group, current_user) + unless can?(current_user, :admin_project, source_project) + raise ValidationError, "Insufficient permissions to modify the job token scope" + end + + raise ValidationError, TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND unless can?(current_user, :read_group, + target_group) + end + + def validate_group_remove!(source_project, current_user) + unless can?(current_user, :admin_project, source_project) + raise ValidationError, "Insufficient permissions to modify the job token scope" + end + end + + def validate_target_exists!(target) + raise NotFoundError, TARGET_DOES_NOT_EXIST if target.nil? + end end end end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 5a62f47d76e9c..6215ed6e89b12 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2229,6 +2229,26 @@ Input type: `CatalogResourcesDestroyInput` | <a id="mutationcatalogresourcesdestroyclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationcatalogresourcesdestroyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.ciJobTokenScopeAddGroupOrProject` + +Input type: `CiJobTokenScopeAddGroupOrProjectInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationcijobtokenscopeaddgrouporprojectclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationcijobtokenscopeaddgrouporprojectprojectpath"></a>`projectPath` | [`ID!`](#id) | Project that the CI job token scope belongs to. | +| <a id="mutationcijobtokenscopeaddgrouporprojecttargetpath"></a>`targetPath` | [`ID!`](#id) | Group or project to be added to the CI job token scope. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationcijobtokenscopeaddgrouporprojectcijobtokenscope"></a>`ciJobTokenScope` | [`CiJobTokenScopeType`](#cijobtokenscopetype) | CI job token's access scope. | +| <a id="mutationcijobtokenscopeaddgrouporprojectclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationcijobtokenscopeaddgrouporprojecterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.ciJobTokenScopeAddProject` Input type: `CiJobTokenScopeAddProjectInput` @@ -2250,6 +2270,26 @@ Input type: `CiJobTokenScopeAddProjectInput` | <a id="mutationcijobtokenscopeaddprojectclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationcijobtokenscopeaddprojecterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.ciJobTokenScopeRemoveGroup` + +Input type: `CiJobTokenScopeRemoveGroupInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationcijobtokenscoperemovegroupclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationcijobtokenscoperemovegroupprojectpath"></a>`projectPath` | [`ID!`](#id) | Project that the CI job token scope belongs to. | +| <a id="mutationcijobtokenscoperemovegrouptargetgrouppath"></a>`targetGroupPath` | [`ID!`](#id) | Group to be removed from the CI job token scope. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationcijobtokenscoperemovegroupcijobtokenscope"></a>`ciJobTokenScope` | [`CiJobTokenScopeType`](#cijobtokenscopetype) | CI job token's access scope. | +| <a id="mutationcijobtokenscoperemovegroupclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationcijobtokenscoperemovegrouperrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.ciJobTokenScopeRemoveProject` Input type: `CiJobTokenScopeRemoveProjectInput` @@ -15995,6 +16035,7 @@ CI/CD variables for a GitLab instance. | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="cijobtokenscopetypegroupsallowlist"></a>`groupsAllowlist` | [`GroupConnection!`](#groupconnection) | Allow list of groups that can access the current project through its CI Job tokens. (see [Connections](#connections)) | | <a id="cijobtokenscopetypeinboundallowlist"></a>`inboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that can access the current project through its CI Job tokens. (see [Connections](#connections)) | | <a id="cijobtokenscopetypeoutboundallowlist"></a>`outboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that are accessible using the current project's CI Job tokens. (see [Connections](#connections)) | | <a id="cijobtokenscopetypeprojects"></a>`projects` **{warning-solid}** | [`ProjectConnection!`](#projectconnection) | **Deprecated** in 15.9. The `projects` attribute is being deprecated. Use `outbound_allowlist`. | diff --git a/rubocop/cop/graphql/id_type.rb b/rubocop/cop/graphql/id_type.rb index deed68da0b33e..430f69ff67c08 100644 --- a/rubocop/cop/graphql/id_type.rb +++ b/rubocop/cop/graphql/id_type.rb @@ -7,7 +7,7 @@ class IDType < RuboCop::Cop::Base MSG = 'Do not use GraphQL::Types::ID, use a specific GlobalIDType instead' ALLOWLISTED_ARGUMENTS = %i[ - iid full_path project_path group_path target_project_path namespace_path + iid full_path project_path group_path target_project_path target_group_path target_path namespace_path context_namespace_path ].freeze diff --git a/spec/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb b/spec/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb new file mode 100644 index 0000000000000..1c8c8e9c2908e --- /dev/null +++ b/spec/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Ci::JobTokenScope::AddGroupOrProject, feature_category: :continuous_integration do + let(:mutation) do + described_class.new(object: nil, context: { current_user: current_user }, field: nil) + end + + describe '#resolve' do + let_it_be(:project) do + create(:project, ci_inbound_job_token_scope_enabled: true).tap(&:save!) + end + + subject(:resolver) do + mutation.resolve(**mutation_args) + end + + shared_examples 'when user is not logged in' do + let(:current_user) { nil } + + it 'raises error' do + expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + shared_examples 'when user does not have permissions to admin project' do + it 'raises error' do + expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when we add a project' do + let_it_be(:target_project) { create(:project) } + + let(:target_project_path) { target_project.full_path } + let(:mutation_args) { { project_path: project.full_path, target_path: target_project_path } } + + it_behaves_like 'when user is not logged in' + + context 'when user is logged in' do + let_it_be(:current_user) { create(:user) } + + it_behaves_like 'when user does not have permissions to admin project' + + context 'when user has permissions to admin project and read target project' do + before_all do + project.add_maintainer(current_user) + target_project.add_guest(current_user) + end + + it 'adds target project to the inbound job token scope by default' do + expect do + expect(resolver).to include(ci_job_token_scope: be_present, errors: be_empty) + end.to change { Ci::JobToken::ProjectScopeLink.count }.by(1) + end + end + + context 'when user has no permissions to read target project' do + before_all do + project.add_maintainer(current_user) + end + + it 'returns an error message' do + response = resolver + + expect(response.fetch(:errors)) + .to include(Ci::JobTokenScope::EditScopeValidations::TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND) + end + end + end + end + + context 'when we add a group' do + let_it_be(:target_group) { create(:group, :private) } + + let(:target_group_path) { target_group.full_path } + let(:mutation_args) { { project_path: project.full_path, target_path: target_group_path } } + + it_behaves_like 'when user is not logged in' + + context 'when user is logged in' do + let_it_be(:current_user) { create(:user) } + + it_behaves_like 'when user does not have permissions to admin project' + + context 'when user has permissions to admin project and read target group' do + before_all do + project.add_maintainer(current_user) + target_group.add_guest(current_user) + end + + it 'adds target group to the job token scope' do + expect do + expect(resolver).to include(ci_job_token_scope: be_present, errors: be_empty) + end.to change { Ci::JobToken::GroupScopeLink.count }.by(1) + end + end + + context 'when user has no permissions to admin project' do + before_all do + target_group.add_guest(current_user) + end + + it 'raises an error' do + expect do + resolver + end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when user has no permissions to read target group' do + before_all do + project.add_maintainer(current_user) + end + + it 'returns an error message' do + response = resolver + + expect(response.fetch(:errors)) + .to include(::Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND) + end + end + end + end + end +end diff --git a/spec/graphql/mutations/ci/job_token_scope/remove_group_spec.rb b/spec/graphql/mutations/ci/job_token_scope/remove_group_spec.rb new file mode 100644 index 0000000000000..f17b34ba2d837 --- /dev/null +++ b/spec/graphql/mutations/ci/job_token_scope/remove_group_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Ci::JobTokenScope::RemoveGroup, feature_category: :continuous_integration do + let(:mutation) do + described_class.new(object: nil, context: { current_user: current_user }, field: nil) + end + + describe '#resolve' do + let_it_be(:project) { create(:project, ci_inbound_job_token_scope_enabled: true).tap(&:save!) } + let_it_be(:target_group) { create(:group, :private) } + + let_it_be(:link) do + create(:ci_job_token_group_scope_link, + source_project: project, + target_group: target_group) + end + + let(:target_group_path) { target_group.full_path } + let(:links_relation) { Ci::JobToken::GroupScopeLink.with_source(project).with_target(target_group) } + + subject(:resolver) do + mutation.resolve(project_path: project.full_path, target_group_path: target_group_path) + end + + context 'when user is not logged in' do + let_it_be(:current_user) { nil } + + it 'raises error' do + expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when user is logged in' do + let_it_be(:current_user) { create(:user) } + + context 'when user does not have permissions to admin project' do + it 'raises error' do + expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when user has permissions to admin project and read target project' do + before_all do + project.add_maintainer(current_user) + target_group.add_guest(current_user) + end + + let(:service) { instance_double('Ci::JobTokenScope::RemoveGroupService') } + + it 'calls the RemoveGroupService to remove a group' do + expect(::Ci::JobTokenScope::RemoveGroupService) + .to receive(:new).with(project, current_user).and_return(service) + expect(service).to receive(:execute).with(target_group) + .and_return(instance_double('ServiceResponse', success?: true)) + + resolver + end + end + + context 'when the service returns an error' do + let(:service) { instance_double('Ci::JobTokenScope::RemoveGroupService') } + + before_all do + project.add_maintainer(current_user) + target_group.add_guest(current_user) + end + + it 'returns an error response' do + expect(::Ci::JobTokenScope::RemoveGroupService).to receive(:new).with(project, + current_user).and_return(service) + expect(service).to receive(:execute).with(target_group) + .and_return(ServiceResponse.error(message: 'The error message')) + + expect(resolver.fetch(:ci_job_token_scope)).to be_nil + expect(resolver.fetch(:errors)).to include("The error message") + end + end + end + end +end diff --git a/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb b/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb index d2bcc7cd597b8..a48c535e563cc 100644 --- a/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb +++ b/spec/graphql/resolvers/ci/job_token_scope_resolver_spec.rb @@ -49,6 +49,31 @@ end end + context 'when groups list is requested' do + let_it_be(:group) { create(:group, :private) } + let!(:group_scope_link) { create(:ci_job_token_group_scope_link, source_project: project, target_group: group) } + + context 'with access to scope' do + before do + project.add_member(current_user, :maintainer) + end + + it 'resolves groups' do + expect(resolve_scope.groups).to contain_exactly(group) + end + + context 'when job token scope is disabled' do + before do + project.update!(ci_inbound_job_token_scope_enabled: false) + end + + it 'resolves groups' do + expect(resolve_scope.groups).to contain_exactly(group) + end + end + end + end + context 'without access to scope' do before do project.add_member(current_user, :developer) diff --git a/spec/graphql/types/ci/job_token_scope_type_spec.rb b/spec/graphql/types/ci/job_token_scope_type_spec.rb index 0104436488113..daf30b417ae9e 100644 --- a/spec/graphql/types/ci/job_token_scope_type_spec.rb +++ b/spec/graphql/types/ci/job_token_scope_type_spec.rb @@ -6,7 +6,7 @@ specify { expect(described_class.graphql_name).to eq('CiJobTokenScopeType') } it 'has the correct fields' do - expected_fields = [:projects, :inboundAllowlist, :outboundAllowlist] + expected_fields = [:projects, :inboundAllowlist, :outboundAllowlist, :groupsAllowlist] expect(described_class).to have_graphql_fields(*expected_fields) end diff --git a/spec/models/ci/job_token/allowlist_spec.rb b/spec/models/ci/job_token/allowlist_spec.rb index feea8758490d8..dd45e69217e99 100644 --- a/spec/models/ci/job_token/allowlist_spec.rb +++ b/spec/models/ci/job_token/allowlist_spec.rb @@ -42,6 +42,28 @@ end end + context 'when no groups are added to the scope' do + subject(:groups) { allowlist.groups } + + it 'returns an empty list' do + expect(groups).to be_empty + end + end + + context 'when groups are added to the scope' do + subject(:groups) { allowlist.groups } + + let_it_be(:target_group) { create(:group) } + + include_context 'with projects that are with and without groups added in allowlist' + + with_them do + it 'returns all groups that are allowed access in the job token scope' do + expect(groups).to contain_exactly(target_group) + end + end + end + describe 'add!' do let_it_be(:added_project) { create(:project) } let_it_be(:user) { create(:user) } @@ -64,6 +86,22 @@ end end + describe 'add_group!' do + let_it_be(:added_group) { create(:group) } + let_it_be(:user) { create(:user) } + + subject { allowlist.add_group!(added_group, user: user) } + + it 'adds the group' do + subject + + expect(allowlist.groups).to contain_exactly(added_group) + expect(subject.added_by_id).to eq(user.id) + expect(subject.source_project_id).to eq(source_project.id) + expect(subject.target_group_id).to eq(added_group.id) + end + end + describe '#includes_project?' do subject { allowlist.includes_project?(includes_project) } @@ -127,7 +165,7 @@ end context 'with a group in each allowlist' do - include_context 'with accessible and inaccessible project groups' + include_context 'with projects that are with and without groups added in allowlist' where(:source_project, :result) do ref(:project_with_target_project_group_in_allowlist) | true diff --git a/spec/models/ci/job_token/scope_spec.rb b/spec/models/ci/job_token/scope_spec.rb index f2ad674b3f530..56714ac323d84 100644 --- a/spec/models/ci/job_token/scope_spec.rb +++ b/spec/models/ci/job_token/scope_spec.rb @@ -57,6 +57,28 @@ end end + describe '#groups' do + subject { scope.groups } + + context 'when no groups are added to the scope' do + it 'returns an empty list' do + expect(subject).to be_empty + end + end + + context 'when groups are added to the scope' do + let_it_be(:target_group) { create(:group) } + + include_context 'with projects that are with and without groups added in allowlist' + + with_them do + it 'returns all groups that are allowed access in the job token scope' do + expect(subject).to contain_exactly(target_group) + end + end + end + end + describe 'accessible?' do subject { scope.accessible?(accessed_project) } @@ -71,7 +93,7 @@ let(:scope) { described_class.new(target_project) } - include_context 'with accessible and inaccessible project groups' + include_context 'with projects that are with and without groups added in allowlist' where(:accessed_project, :result) do ref(:project_with_target_project_group_in_allowlist) | true diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb new file mode 100644 index 0000000000000..bc23ec206a209 --- /dev/null +++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/add_group_or_project_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'CiJobTokenScopeAddGroupOrProject', feature_category: :continuous_integration do + include GraphqlHelpers + + let_it_be(:project) { create(:project, ci_inbound_job_token_scope_enabled: true).tap(&:save!) } + let(:mutation_response) { graphql_mutation_response(:ci_job_token_scope_add_group_or_project) } + + let(:variables) do + { + project_path: project.full_path, + target_path: target_path.full_path + } + end + + let(:mutation) do + graphql_mutation(:ci_job_token_scope_add_group_or_project, variables) do + <<~QL + errors + ciJobTokenScope { + groupsAllowlist { + nodes { + path + } + } + inboundAllowlist { + nodes { + path + } + } + } + QL + end + end + + shared_examples 'not authorized' do + let_it_be(:current_user) { create(:user) } + + context 'when not a maintainer' do + before_all do + project.add_developer(current_user) + end + + it 'has graphql errors' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_errors).not_to be_empty + end + end + end + + shared_examples 'invalid target' do + before do + variables[:target_path] = 'unknown/project_or_group' + end + + it 'has mutation errors' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['errors']) + .to contain_exactly(::Ci::JobTokenScope::EditScopeValidations::TARGET_DOES_NOT_EXIST) + end + end + + context 'when we add a group' do + let_it_be(:target_group) { create(:group, :private) } + let(:target_path) { target_group } + + it_behaves_like 'not authorized' + + context 'when authorized' do + let_it_be(:current_user) { project.first_owner } + + before_all do + target_group.add_developer(current_user) + end + + it 'adds the target group to the job token scope' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response.dig('ciJobTokenScope', 'groupsAllowlist', 'nodes')).not_to be_empty + end.to change { Ci::JobToken::GroupScopeLink.count }.by(1) + end + + it_behaves_like 'invalid target' + end + end + + context 'when we add a project' do + let_it_be(:target_project) { create(:project) } + let(:target_path) { target_project } + + it_behaves_like 'not authorized' + + context 'when authorized' do + let_it_be(:current_user) { project.first_owner } + + before_all do + target_project.add_developer(current_user) + end + + it 'adds the target project to the job token scope' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response.dig('ciJobTokenScope', 'inboundAllowlist', 'nodes')).not_to be_empty + end.to change { Ci::JobToken::ProjectScopeLink.count }.by(1) + end + + it_behaves_like 'invalid target' + end + end +end diff --git a/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_group_spec.rb b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_group_spec.rb new file mode 100644 index 0000000000000..525d7c9edb20d --- /dev/null +++ b/spec/requests/api/graphql/mutations/ci/job_token_scope/remove_group_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'CiJobTokenScopeRemoveGroup', feature_category: :continuous_integration do + include GraphqlHelpers + + let_it_be(:project) do + create(:project, + ci_inbound_job_token_scope_enabled: true + ) + end + + let_it_be(:target_group) { create(:group, :private) } + + let_it_be(:link) do + create(:ci_job_token_group_scope_link, + source_project: project, + target_group: target_group) + end + + let(:variables) do + { + project_path: project.full_path, + target_group_path: target_group.full_path + } + end + + let(:mutation) do + graphql_mutation(:ci_job_token_scope_remove_group, variables) do + <<~QL + errors + ciJobTokenScope { + groupsAllowlist { + nodes { + path + } + } + } + QL + end + end + + let(:mutation_response) { graphql_mutation_response(:ci_job_token_scope_remove_group) } + + context 'when unauthorized' do + let_it_be(:current_user) { create(:user) } + + context 'when not a maintainer' do + before_all do + project.add_developer(current_user) + end + + it 'has graphql errors' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_errors).not_to be_empty + end + end + end + + context 'when authorized' do + let_it_be(:current_user) { project.first_owner } + + before_all do + target_group.add_guest(current_user) + end + + it 'removes the target group from the job token scope' do + expect do + post_graphql_mutation(mutation, current_user: current_user) + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response.dig('ciJobTokenScope', 'groupsAllowlist', 'nodes')).to be_empty + end.to change { Ci::JobToken::GroupScopeLink.count }.by(-1) + end + + context 'when invalid target group is provided' do + before do + variables[:target_group_path] = 'unknown/project' + end + + it 'has mutation errors' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['errors']) + .to contain_exactly('Target group is not in the job token scope') + end + end + end +end diff --git a/spec/services/ci/job_token_scope/add_group_or_project_service_spec.rb b/spec/services/ci/job_token_scope/add_group_or_project_service_spec.rb new file mode 100644 index 0000000000000..8ec6961f93eb9 --- /dev/null +++ b/spec/services/ci/job_token_scope/add_group_or_project_service_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobTokenScope::AddGroupOrProjectService, feature_category: :continuous_integration do + subject(:service_execute) { described_class.new(source_project, current_user).execute(target) } + + let_it_be(:source_project) { create(:project) } + let_it_be(:target_project) { create(:project) } + let_it_be(:target_group) { create(:group) } + let_it_be(:current_user) { create(:user) } + let(:response_success) { ServiceResponse.success } + + describe '#execute' do + context 'when group is a target to add' do + let(:target) { target_group } + let(:add_group_service_double) { instance_double(::Ci::JobTokenScope::AddGroupService) } + + before do + allow(::Ci::JobTokenScope::AddGroupService).to receive(:new) + .with(source_project, current_user) + .and_return(add_group_service_double) + end + + it 'calls AddGroupService to add a target' do + expect(add_group_service_double) + .to receive(:execute).with(target) + .and_return(response_success) + + expect(service_execute).to eq(response_success) + end + end + + context 'when project is a target to add' do + let(:target) { target_project } + let(:add_project_service_double) { instance_double(::Ci::JobTokenScope::AddProjectService) } + + before do + allow(::Ci::JobTokenScope::AddProjectService).to receive(:new) + .with(source_project, current_user) + .and_return(add_project_service_double) + end + + it 'calls AddProjectService to add a target' do + expect(add_project_service_double) + .to receive(:execute).with(target) + .and_return(response_success) + + expect(service_execute).to eq(response_success) + end + end + + context 'when not found object is a target to add' do + let(:target) { nil } + let(:expected_error_message) do + Ci::JobTokenScope::EditScopeValidations::TARGET_DOES_NOT_EXIST + end + + it 'returns a response error' do + response = service_execute + + expect(response).to be_kind_of(ServiceResponse) + expect(response).to be_error + expect(response.message).to eq(expected_error_message) + expect(response.reason).to eq(:not_found) + end + end + end +end diff --git a/spec/services/ci/job_token_scope/add_group_service_spec.rb b/spec/services/ci/job_token_scope/add_group_service_spec.rb new file mode 100644 index 0000000000000..4b2eafb9a3947 --- /dev/null +++ b/spec/services/ci/job_token_scope/add_group_service_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobTokenScope::AddGroupService, feature_category: :continuous_integration do + let(:service) { described_class.new(project, current_user) } + + let_it_be(:project) { create(:project, ci_outbound_job_token_scope_enabled: true).tap(&:save!) } + let_it_be(:target_group) { create(:group, :private) } + let_it_be(:current_user) { create(:user) } + + shared_examples 'adds group' do |_context| + it 'adds the group to the scope' do + expect do + expect(result).to be_success + end.to change { Ci::JobToken::GroupScopeLink.count }.by(1) + end + end + + describe '#execute' do + subject(:result) { service.execute(target_group) } + + it_behaves_like 'editable group job token scope' do + context 'when user has permissions on source and target groups' do + before_all do + project.add_maintainer(current_user) + target_group.add_developer(current_user) + end + + it_behaves_like 'adds group' + + context 'when token scope is disabled' do + before do + project.ci_cd_settings.update!(job_token_scope_enabled: false) + end + + it_behaves_like 'adds group' + end + end + + context 'when group is already in the allowlist' do + before_all do + project.add_maintainer(current_user) + target_group.add_developer(current_user) + end + + before do + service.execute(target_group) + end + + it_behaves_like 'returns error', 'Target group is already in the job token scope' + end + + context 'when create method raises an invalid record exception' do + before do + allow_next_instance_of(Ci::JobToken::Allowlist) do |link| + allow(link) + .to receive(:add_group!) + .and_raise(ActiveRecord::RecordInvalid) + end + end + + before_all do + project.add_maintainer(current_user) + target_group.add_developer(current_user) + end + + it_behaves_like 'returns error', 'Record invalid' + end + + context 'when has no permissions on a target_group' do + before_all do + project.add_maintainer(current_user) + end + + it_behaves_like 'returns error', Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND + end + + context 'when has no permissions on a project' do + before_all do + target_group.add_developer(current_user) + end + + it_behaves_like 'returns error', 'Insufficient permissions to modify the job token scope' + end + end + end +end diff --git a/spec/services/ci/job_token_scope/remove_group_service_spec.rb b/spec/services/ci/job_token_scope/remove_group_service_spec.rb new file mode 100644 index 0000000000000..25143aa517fa5 --- /dev/null +++ b/spec/services/ci/job_token_scope/remove_group_service_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobTokenScope::RemoveGroupService, feature_category: :continuous_integration do + let(:service) { described_class.new(project, current_user) } + + let_it_be(:project) { create(:project, ci_outbound_job_token_scope_enabled: true).tap(&:save!) } + let_it_be(:target_group) { create(:group, :private) } + let_it_be(:current_user) { create(:user) } + + let_it_be(:link) do + create(:ci_job_token_group_scope_link, + source_project: project, + target_group: target_group) + end + + shared_examples 'removes group' do + it 'removes the group from the scope' do + expect do + expect(result).to be_success + end.to change { Ci::JobToken::GroupScopeLink.count }.by(-1) + end + end + + shared_examples 'returns error' do |error| + it 'returns an error response', :aggregate_failures do + expect(result).to be_error + expect(result.message).to eq(error) + end + end + + describe '#execute' do + subject(:result) { service.execute(target_group) } + + context 'when user has permissions on source and target group' do + before_all do + project.add_maintainer(current_user) + target_group.add_developer(current_user) + end + + it_behaves_like 'removes group' + + context 'when token scope is disabled' do + before do + project.ci_cd_settings.update!(job_token_scope_enabled: false) + end + + it_behaves_like 'removes group' + end + end + + context 'when user has no permissions on target_group' do + before_all do + project.add_maintainer(current_user) + end + + it_behaves_like 'removes group' + end + + context 'when target group is not in the job token scope' do + let_it_be(:target_group) { create(:group, :public) } + + before_all do + project.add_maintainer(current_user) + + expect(target_group.id).not_to eq(link.target_group_id) + end + + it_behaves_like 'returns error', 'Target group is not in the job token scope' + end + + context 'when user has no permissions on source project' do + before_all do + target_group.add_developer(current_user) + end + + it_behaves_like 'returns error', 'Insufficient permissions to modify the job token scope' + end + + context 'when there is error to delete a link' do + before_all do + project.add_maintainer(current_user) + target_group.add_developer(current_user) + end + + before do + allow(::Ci::JobToken::GroupScopeLink).to receive(:for_source_and_target).and_return(link) + allow(link).to receive(:destroy).and_return(false) + allow(link).to receive(:errors).and_return(ActiveModel::Errors.new(link)) + link.errors.add(:base, 'Custom error message') + end + + it_behaves_like 'returns error', 'Custom error message' + end + end +end diff --git a/spec/services/concerns/ci/job_token_scope/edit_scope_validations_spec.rb b/spec/services/concerns/ci/job_token_scope/edit_scope_validations_spec.rb new file mode 100644 index 0000000000000..cad345e58b36b --- /dev/null +++ b/spec/services/concerns/ci/job_token_scope/edit_scope_validations_spec.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::JobTokenScope::EditScopeValidations, feature_category: :continuous_integration do + using RSpec::Parameterized::TableSyntax + + let(:test_class) do + Class.new(::BaseService) do + include Ci::JobTokenScope::EditScopeValidations + end + end + + let_it_be(:source_project) { create(:project) } + let_it_be(:target_project) { create(:project) } + let_it_be(:target_group) { create(:group) } + let_it_be(:current_user) { create(:user) } + + subject(:test_instance) { test_class.new(source_project, current_user) } + + describe '#validate_edit!' do + subject(:validate_edit_execution) do + test_instance.validate_edit!(source_project, target_project, current_user) + end + + before do + source_project.send("add_#{source_project_user_role}", current_user) if source_project_user_role + target_project.send("add_#{target_project_user_role}", current_user) if target_project_user_role + source_project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(source_project_visibility, false)) + target_project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(target_project_visibility, false)) + end + + context 'when all permissions are in order' do + where(:source_project_visibility, + :target_project_visibility, + :source_project_user_role, + :target_project_user_role) do + 'PUBLIC' | 'PUBLIC' | :maintainer | :developer + 'PUBLIC' | 'PUBLIC' | :maintainer | :guest + 'PRIVATE' | 'PRIVATE' | :maintainer | :developer + 'PRIVATE' | 'PRIVATE' | :maintainer | :guest + end + + with_them do + it 'passes the validation' do + expect do + validate_edit_execution + end.not_to raise_error + end + end + end + + context 'when user lacks admin_project permissions for the source project' do + where(:source_project_visibility, + :target_project_visibility, + :source_project_user_role, + :target_project_user_role) do + 'PUBLIC' | 'PUBLIC' | nil | :developer + 'PRIVATE' | 'PRIVATE' | nil | :developer + 'PUBLIC' | 'PUBLIC' | :guest | :developer + 'PRIVATE' | 'PRIVATE' | :guest | :developer + 'PRIVATE' | 'PRIVATE' | :developer | :developer + 'PUBLIC' | 'PRIVATE' | :developer | :developer + end + + with_them do + it 'raises an error' do + expect do + validate_edit_execution + end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError, + 'Insufficient permissions to modify the job token scope') + end + end + end + + context 'when user lacks read_project permissions for the target project' do + where(:source_project_visibility, + :target_project_visibility, + :source_project_user_role, + :target_project_user_role) do + 'PRIVATE' | 'PRIVATE' | :maintainer | nil + 'PUBLIC' | 'PRIVATE' | :maintainer | nil + end + + with_them do + it 'raises an error' do + expect do + validate_edit_execution + end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError, + Ci::JobTokenScope::EditScopeValidations::TARGET_PROJECT_UNAUTHORIZED_OR_UNFOUND) + end + end + end + end + + describe '#validate_group_add!' do + subject(:validate_group_execution) do + test_instance.validate_group_add!(source_project, target_group, current_user) + end + + before do + source_project.send("add_#{source_project_user_role}", current_user) if source_project_user_role + target_group.send("add_#{target_group_user_role}", current_user) if target_group_user_role + source_project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(source_project_visibility, false)) + target_group.update!(visibility_level: Gitlab::VisibilityLevel.const_get(target_group_visibility, false)) + end + + context 'when all permissions are in order' do + where(:source_project_visibility, + :target_group_visibility, + :source_project_user_role, + :target_group_user_role) do + 'PUBLIC' | 'PUBLIC' | :maintainer | :developer + 'PUBLIC' | 'PUBLIC' | :maintainer | :guest + 'PRIVATE' | 'PRIVATE' | :maintainer | :developer + 'PRIVATE' | 'PRIVATE' | :maintainer | :guest + end + + with_them do + it 'passes the validation' do + expect do + validate_group_execution + end.not_to raise_error + end + end + end + + context 'when user lacks admin_project permissions for the source project' do + where(:source_project_visibility, + :target_group_visibility, + :source_project_user_role, + :target_group_user_role) do + 'PUBLIC' | 'PUBLIC' | nil | :developer + 'PRIVATE' | 'PRIVATE' | nil | :developer + 'PUBLIC' | 'PUBLIC' | :guest | :developer + 'PRIVATE' | 'PRIVATE' | :guest | :developer + 'PRIVATE' | 'PRIVATE' | :developer | :developer + 'PUBLIC' | 'PRIVATE' | :developer | :developer + end + + with_them do + it 'raises an error' do + expect do + validate_group_execution + end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError, + 'Insufficient permissions to modify the job token scope') + end + end + end + + context 'when user lacks read_project permissions for the target group' do + where(:source_project_visibility, + :target_group_visibility, + :source_project_user_role, + :target_group_user_role) do + 'PRIVATE' | 'PRIVATE' | :maintainer | nil + 'PUBLIC' | 'PRIVATE' | :maintainer | nil + end + + with_them do + it 'raises an error' do + expect do + validate_group_execution + end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError, + Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND) + end + end + end + end + + describe '#validate_group_remove!' do + subject(:validate_group_execution) do + test_instance.validate_group_remove!(source_project, current_user) + end + + before do + source_project.send("add_#{source_project_user_role}", current_user) if source_project_user_role + target_group.send("add_#{target_group_user_role}", current_user) if target_group_user_role + source_project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(source_project_visibility, false)) + target_group.update!(visibility_level: Gitlab::VisibilityLevel.const_get(target_group_visibility, false)) + end + + context 'when all permissions are in order' do + where(:source_project_visibility, + :target_group_visibility, + :source_project_user_role, + :target_group_user_role) do + 'PUBLIC' | 'PUBLIC' | :maintainer | :developer + 'PUBLIC' | 'PUBLIC' | :maintainer | :guest + 'PRIVATE' | 'PRIVATE' | :maintainer | :developer + 'PRIVATE' | 'PRIVATE' | :maintainer | :guest + 'PUBLIC' | 'PUBLIC' | :maintainer | nil + 'PUBLIC' | 'PUBLIC' | :maintainer | nil + 'PRIVATE' | 'PRIVATE' | :maintainer | nil + 'PRIVATE' | 'PRIVATE' | :maintainer | nil + end + + with_them do + it 'passes the validation' do + expect do + validate_group_execution + end.not_to raise_error + end + end + end + + context 'when user lacks admin_project permissions for the source project' do + where(:source_project_visibility, + :target_group_visibility, + :source_project_user_role, + :target_group_user_role) do + 'PUBLIC' | 'PUBLIC' | nil | :developer + 'PRIVATE' | 'PRIVATE' | nil | :developer + 'PUBLIC' | 'PUBLIC' | :guest | :developer + 'PRIVATE' | 'PRIVATE' | :guest | :developer + 'PRIVATE' | 'PRIVATE' | :developer | :developer + 'PUBLIC' | 'PRIVATE' | :developer | :developer + end + + with_them do + it 'raises an error' do + expect do + validate_group_execution + end.to raise_error(Ci::JobTokenScope::EditScopeValidations::ValidationError, + 'Insufficient permissions to modify the job token scope') + end + end + end + end + + describe '#validate_target_exists!' do + subject(:validate_target_exists_execution) do + test_instance.validate_target_exists!(target) + end + + context 'when target is nil' do + let_it_be(:target) { nil } + + it 'raises an error' do + expect do + validate_target_exists_execution + end.to raise_error(Ci::JobTokenScope::EditScopeValidations::NotFoundError, + Ci::JobTokenScope::EditScopeValidations::TARGET_DOES_NOT_EXIST) + end + end + + context 'when target is present' do + let_it_be(:target) { target_project } + + it 'raises an error' do + expect do + validate_target_exists_execution + end.not_to raise_error + end + end + end +end diff --git a/spec/support/shared_contexts/models/ci/job_token_scope.rb b/spec/support/shared_contexts/models/ci/job_token_scope.rb index c363ff443baee..865bc6c7c8156 100644 --- a/spec/support/shared_contexts/models/ci/job_token_scope.rb +++ b/spec/support/shared_contexts/models/ci/job_token_scope.rb @@ -14,7 +14,7 @@ include_context 'with inaccessible projects' end -RSpec.shared_context 'with accessible and inaccessible project groups' do +RSpec.shared_context 'with projects that are with and without groups added in allowlist' do let_it_be(:project_with_target_project_group_in_allowlist) { create_project_with_group_allowlist(target_project) } let_it_be(:project_wo_target_project_group_in_allowlist) { create_project_without_group_allowlist } end diff --git a/spec/support/shared_examples/ci/edit_group_job_token_scope_shared_examples.rb b/spec/support/shared_examples/ci/edit_group_job_token_scope_shared_examples.rb new file mode 100644 index 0000000000000..9d7b4591b7a94 --- /dev/null +++ b/spec/support/shared_examples/ci/edit_group_job_token_scope_shared_examples.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'editable group job token scope' do + shared_examples 'returns error' do |error| + it 'returns an error response', :aggregate_failures do + expect(result).to be_error + expect(result.message).to eq(error) + end + end + + context 'when user does not have permissions to edit the job token scope' do + it_behaves_like 'returns error', 'Insufficient permissions to modify the job token scope' + end + + context 'when user has permissions to edit the job token scope' do + before do + project.add_maintainer(current_user) + end + + context 'when target group is not provided' do + let(:target_group) { nil } + + it_behaves_like 'returns error', Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND + end + + context 'when target group is provided' do + context 'when user does not have permissions to read the target group' do + it_behaves_like 'returns error', Ci::JobTokenScope::EditScopeValidations::TARGET_GROUP_UNAUTHORIZED_OR_UNFOUND + end + end + end +end -- GitLab