diff --git a/app/graphql/mutations/ci/job_token_scope/autopopulate_allowlist.rb b/app/graphql/mutations/ci/job_token_scope/autopopulate_allowlist.rb new file mode 100644 index 0000000000000000000000000000000000000000..202d153e9d018a4c5627b5d5af280e068e7f3432 --- /dev/null +++ b/app/graphql/mutations/ci/job_token_scope/autopopulate_allowlist.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module JobTokenScope + class AutopopulateAllowlist < BaseMutation + graphql_name 'CiJobTokenScopeAutopopulateAllowlist' + + include FindsProject + + authorize :admin_project + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Project in which to autopopulate the allowlist.' + + field :status, + GraphQL::Types::String, + null: false, + description: "Status of the autopopulation process." + + def resolve(project_path:) + project = authorized_find!(project_path) + + result = ::Ci::JobToken::ClearAutopopulatedAllowlistService.new(project, current_user).execute + result = ::Ci::JobToken::AutopopulateAllowlistService.new(project, current_user).execute if result.success? + + if result.success? + { status: "complete", errors: [] } + else + { status: "error", errors: [result.message] } + end + end + end + end + end +end diff --git a/app/graphql/mutations/ci/job_token_scope/clear_allowlist_autopopulations.rb b/app/graphql/mutations/ci/job_token_scope/clear_allowlist_autopopulations.rb new file mode 100644 index 0000000000000000000000000000000000000000..d083c20172b16cccf98909d841f12cf68ea2f3ca --- /dev/null +++ b/app/graphql/mutations/ci/job_token_scope/clear_allowlist_autopopulations.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module JobTokenScope + class ClearAllowlistAutopopulations < BaseMutation + graphql_name 'CiJobTokenScopeClearAllowlistAutopopulations' + + include FindsProject + + authorize :admin_project + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Project in which to autopopulate the allowlist.' + + field :status, + GraphQL::Types::String, + null: false, + description: "Status of the autopopulation process." + + def resolve(project_path:) + project = authorized_find!(project_path) + + result = ::Ci::JobToken::ClearAutopopulatedAllowlistService.new(project, current_user).execute + + if result.success? + { status: "complete", errors: [] } + else + { status: "error", errors: [result.message] } + end + end + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 7549ca5e147a4e5233ca9810703a632fd400aea1..81cc7dd220257be122d0ee506bcd55c9655baa52 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -180,6 +180,8 @@ class MutationType < BaseObject mount_mutation Mutations::Ci::JobTokenScope::RemoveGroup mount_mutation Mutations::Ci::JobTokenScope::RemoveProject mount_mutation Mutations::Ci::JobTokenScope::UpdateJobTokenPolicies, experiment: { milestone: '17.6' } + mount_mutation Mutations::Ci::JobTokenScope::AutopopulateAllowlist, experiment: { milestone: '17.9' } + mount_mutation Mutations::Ci::JobTokenScope::ClearAllowlistAutopopulations, experiment: { milestone: '17.9' } mount_mutation Mutations::Ci::Pipeline::Cancel mount_mutation Mutations::Ci::Pipeline::Create 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 80a640b791964753eddde4960840586886873420..15498a3ac70d987d021d07bb6e6d9380a8883dc9 100644 --- a/app/models/ci/job_token/allowlist.rb +++ b/app/models/ci/job_token/allowlist.rb @@ -81,6 +81,7 @@ def bulk_add_projects!(target_projects, user:, autopopulated: false, policies: [ autopopulated: autopopulated, added_by: user, job_token_policies: job_token_policies, + direction: @direction, created_at: now ) end diff --git a/app/services/ci/job_token/autopopulate_allowlist_service.rb b/app/services/ci/job_token/autopopulate_allowlist_service.rb index f11e6d4921b5ebf1979cfee57d6f47805992e02c..d23a8b95a486ba17e7e6c5ff1279136b2dbd9257 100644 --- a/app/services/ci/job_token/autopopulate_allowlist_service.rb +++ b/app/services/ci/job_token/autopopulate_allowlist_service.rb @@ -24,6 +24,15 @@ def execute allowlist.bulk_add_groups!(groups, user: @user, autopopulated: true) allowlist.bulk_add_projects!(projects, user: @user, autopopulated: true) end + + ServiceResponse.success + rescue Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError, + Gitlab::Utils::TraversalIdCompactor::RedundantCompactionEntry, + Gitlab::Utils::TraversalIdCompactor::UnexpectedCompactionEntry, + Ci::JobToken::AuthorizationsCompactor::UnexpectedCompactionEntry, + Ci::JobToken::AuthorizationsCompactor::RedundantCompactionEntry => e + Gitlab::ErrorTracking.log_exception(e, { project_id: @project.id, user_id: @user.id }) + ServiceResponse.error(message: e.message) end private diff --git a/app/services/ci/job_token/clear_autopopulated_allowlist_service.rb b/app/services/ci/job_token/clear_autopopulated_allowlist_service.rb index 24e1761bc285736485fbdde83a899b3f02d654b5..1ba8c2c2f5fa9e91fe569033c33650d201119380 100644 --- a/app/services/ci/job_token/clear_autopopulated_allowlist_service.rb +++ b/app/services/ci/job_token/clear_autopopulated_allowlist_service.rb @@ -17,6 +17,8 @@ def execute allowlist.project_links.autopopulated.delete_all allowlist.group_links.autopopulated.delete_all end + + ServiceResponse.success end private diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a3db407a3f814e26df97df3e34b632a5cb48dce2..47cbb3dc6333c54763ca80f2cb683138d05d2c13 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -3212,6 +3212,52 @@ Input type: `CiJobTokenScopeAddProjectInput` | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.ciJobTokenScopeAutopopulateAllowlist` + +DETAILS: +**Introduced** in GitLab 17.9. +**Status**: Experiment. + +Input type: `CiJobTokenScopeAutopopulateAllowlistInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `projectPath` | [`ID!`](#id) | Project in which to autopopulate the allowlist. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `status` | [`String!`](#string) | Status of the autopopulation process. | + +### `Mutation.ciJobTokenScopeClearAllowlistAutopopulations` + +DETAILS: +**Introduced** in GitLab 17.9. +**Status**: Experiment. + +Input type: `CiJobTokenScopeClearAllowlistAutopopulationsInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `projectPath` | [`ID!`](#id) | Project in which to autopopulate the allowlist. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| `status` | [`String!`](#string) | Status of the autopopulation process. | + ### `Mutation.ciJobTokenScopeRemoveGroup` Input type: `CiJobTokenScopeRemoveGroupInput` diff --git a/spec/graphql/mutations/ci/job_token_scope/autopopulate_allowlist_spec.rb b/spec/graphql/mutations/ci/job_token_scope/autopopulate_allowlist_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..424d5c8b3fdba32bb05e3ae3d743941cb0752a85 --- /dev/null +++ b/spec/graphql/mutations/ci/job_token_scope/autopopulate_allowlist_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Ci::JobTokenScope::AutopopulateAllowlist, feature_category: :continuous_integration do + include GraphqlHelpers + + let(:mutation) do + described_class.new(object: nil, context: query_context, field: nil) + end + + describe '#resolve' do + let_it_be(:project) { create(:project) } + let_it_be(:origin_project) { create(:project) } + let(:mutation_args) { { project_path: project.full_path } } + + subject(:resolver) do + mutation.resolve(**mutation_args) + end + + context '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 + + context 'when user is logged in' do + let_it_be(:current_user) { create(:user) } + + context 'when user does not have permissions to admin the project' do + it 'raises error' do + expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when user has permissions to admin the project' do + before_all do + project.add_maintainer(current_user) + + create(:ci_job_token_authorization, origin_project: origin_project, accessed_project: project, + last_authorized_at: 1.day.ago) + end + + it 'adds target project to the inbound job token scope by default' do + expect do + expect(resolver).to include(errors: be_empty) + end.to change { Ci::JobToken::ProjectScopeLink.count }.by(1) + end + + context 'when the clear service returns an error' do + let(:service) { instance_double(::Ci::JobToken::ClearAutopopulatedAllowlistService) } + + it 'returns an error response' do + expect(::Ci::JobToken::ClearAutopopulatedAllowlistService).to receive(:new).with(project, + current_user).and_return(service) + expect(service).to receive(:execute) + .and_return(ServiceResponse.error(message: 'Clear service error message')) + + expect(resolver.fetch(:errors)).to include("Clear service error message") + end + end + + context 'when the autopopulate service returns an error' do + let(:service) { instance_double(::Ci::JobToken::AutopopulateAllowlistService) } + + it 'returns an error response' do + expect(::Ci::JobToken::AutopopulateAllowlistService).to receive(:new).with(project, + current_user).and_return(service) + expect(service).to receive(:execute) + .and_return(ServiceResponse.error(message: 'Autopopulates service error message')) + + expect(resolver.fetch(:errors)).to include("Autopopulates service error message") + end + end + end + end + end +end diff --git a/spec/graphql/mutations/ci/job_token_scope/clear_allowlist_autopopulations_spec.rb b/spec/graphql/mutations/ci/job_token_scope/clear_allowlist_autopopulations_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..670d6e785388ac9132dd914bb1ac6985e4776289 --- /dev/null +++ b/spec/graphql/mutations/ci/job_token_scope/clear_allowlist_autopopulations_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Ci::JobTokenScope::ClearAllowlistAutopopulations, feature_category: :continuous_integration do + include GraphqlHelpers + + let(:mutation) do + described_class.new(object: nil, context: query_context, field: nil) + end + + describe '#resolve' do + let_it_be(:project) { create(:project) } + let(:mutation_args) { { project_path: project.full_path } } + + subject(:resolver) do + mutation.resolve(**mutation_args) + end + + context '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 + + context 'when user is logged in' do + let_it_be(:current_user) { create(:user) } + + context 'when user does not have permissions to admin the project' do + it 'raises error' do + expect { resolver }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when user has permissions to admin the project' do + before_all do + project.add_maintainer(current_user) + end + + context 'when group scope links have been autopopulated' do + before do + create(:ci_job_token_group_scope_link, source_project: project, autopopulated: true) + create(:ci_job_token_group_scope_link, source_project: project, autopopulated: false) + end + + it 'removes the autopopulated group links' do + expect do + expect(resolver).to include(errors: be_empty) + end.to change { Ci::JobToken::GroupScopeLink.autopopulated.count }.by(-1) + .and not_change { Ci::JobToken::ProjectScopeLink.autopopulated.count } + end + end + + context 'when project scope links have been autopopulated' do + before do + create(:ci_job_token_project_scope_link, source_project: project, direction: :inbound, + autopopulated: true) + create(:ci_job_token_project_scope_link, source_project: project, direction: :inbound, + autopopulated: false) + end + + it 'removes the autopopulated project links' do + expect do + expect(resolver).to include(errors: be_empty) + end.to change { Ci::JobToken::ProjectScopeLink.autopopulated.count }.by(-1) + .and not_change { Ci::JobToken::GroupScopeLink.autopopulated.count } + end + end + + context 'when the service returns an error' do + let(:service) { instance_double(::Ci::JobToken::ClearAutopopulatedAllowlistService) } + + it 'returns an error response' do + expect(::Ci::JobToken::ClearAutopopulatedAllowlistService).to receive(:new).with(project, + current_user).and_return(service) + expect(service).to receive(:execute) + .and_return(ServiceResponse.error(message: 'The error message')) + + expect(resolver.fetch(:errors)).to include("The error message") + end + end + end + end + end +end diff --git a/spec/services/ci/job_token/autopopulate_allowlist_service_spec.rb b/spec/services/ci/job_token/autopopulate_allowlist_service_spec.rb index e449711fa014383da25dcebe29df64017e316b51..ce607aba21061ffc311ac78de7cbcd903bc05556 100644 --- a/spec/services/ci/job_token/autopopulate_allowlist_service_spec.rb +++ b/spec/services/ci/job_token/autopopulate_allowlist_service_spec.rb @@ -62,7 +62,9 @@ end it 'creates the expected group and project links for the given limit' do - service.execute + result = service.execute + + expect(result).to be_success expect(Ci::JobToken::GroupScopeLink.autopopulated.pluck(:target_group_id)).to match_array([ns2.id, ns4.id]) expect(Ci::JobToken::ProjectScopeLink.autopopulated.pluck(:target_project_id)).to match_array([pns1.project.id, @@ -73,8 +75,9 @@ let(:compaction_limit) { 3 } it 'creates the expected group and project links' do - service.execute + result = service.execute + expect(result).to be_success expect(Ci::JobToken::GroupScopeLink.autopopulated.pluck(:target_group_id)).to match_array([ns1.id]) expect(Ci::JobToken::ProjectScopeLink.autopopulated.pluck(:target_project_id)).to match_array([pns8.project.id]) end @@ -84,9 +87,10 @@ let(:compaction_limit) { 1 } it 'logs a CompactionLimitCannotBeAchievedError error' do - expect do - service.execute - end.to raise_error(Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError) + result = service.execute + + expect(result).to be_error + expect(result.message).to eq('Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError') expect(Ci::JobToken::GroupScopeLink.count).to be(0) expect(Ci::JobToken::ProjectScopeLink.count).to be(0) @@ -102,7 +106,10 @@ original_response << [1, 2, 3] end - expect { service.execute }.to raise_error(Gitlab::Utils::TraversalIdCompactor::UnexpectedCompactionEntry) + result = service.execute + + expect(result).to be_error + expect(result.message).to eq('Gitlab::Utils::TraversalIdCompactor::UnexpectedCompactionEntry') end it 'logs a RedundantCompactionEntry error' do @@ -111,7 +118,10 @@ original_response << original_response.last.first(2) end - expect { service.execute }.to raise_error(Gitlab::Utils::TraversalIdCompactor::RedundantCompactionEntry) + result = service.execute + + expect(result).to be_error + expect(result.message).to eq('Gitlab::Utils::TraversalIdCompactor::RedundantCompactionEntry') end end @@ -137,9 +147,15 @@ let(:compaction_limit) { 2 } it 'raises when the limit cannot be achieved' do - expect do - service.execute - end.to raise_error(Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError) + expect(Gitlab::ErrorTracking).to receive(:log_exception).with( + kind_of(Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError), + { project_id: accessed_project.id, user_id: maintainer.id } + ) + result = service.execute + + expect(result).to be_error + expect(result.message).to eq('Gitlab::Utils::TraversalIdCompactor::CompactionLimitCannotBeAchievedError') + expect(Ci::JobToken::GroupScopeLink.count).to be(0) expect(Ci::JobToken::ProjectScopeLink.count).to be(0) end