diff --git a/app/services/ci/job_token_scope/add_project_service.rb b/app/services/ci/job_token_scope/add_project_service.rb index 704aa28f8c5236849e9769d9772549ff99acb2fe..8fb543a279607ecfab9055ce68167f941708fd82 100644 --- a/app/services/ci/job_token_scope/add_project_service.rb +++ b/app/services/ci/job_token_scope/add_project_service.rb @@ -29,3 +29,5 @@ def allowlist(direction) end end end + +Ci::JobTokenScope::AddProjectService.prepend_mod_with('Ci::JobTokenScope::AddProjectService') diff --git a/app/services/ci/job_token_scope/remove_project_service.rb b/app/services/ci/job_token_scope/remove_project_service.rb index 864f9318c6806711ceaea32d08a33db5f854873e..d6a2defd5b941f82869de6387dc740072fabd4cd 100644 --- a/app/services/ci/job_token_scope/remove_project_service.rb +++ b/app/services/ci/job_token_scope/remove_project_service.rb @@ -31,3 +31,5 @@ def execute(target_project, direction) end end end + +Ci::JobTokenScope::RemoveProjectService.prepend_mod_with('Ci::JobTokenScope::RemoveProjectService') diff --git a/ee/app/graphql/ee/mutations/ci/project_ci_cd_settings_update.rb b/ee/app/graphql/ee/mutations/ci/project_ci_cd_settings_update.rb index a1cb07380c5ec73a17512801e57e15d8a9fb7b88..7a401485be50e9d46508deed6d856b7cf9c46eab 100644 --- a/ee/app/graphql/ee/mutations/ci/project_ci_cd_settings_update.rb +++ b/ee/app/graphql/ee/mutations/ci/project_ci_cd_settings_update.rb @@ -16,6 +16,36 @@ module ProjectCiCdSettingsUpdate required: false, description: 'Indicates if merge trains are enabled for the project.' end + + override :resolve + def resolve(full_path:, **args) + super.tap do |result| + ci_cd_settings = result[:ci_cd_settings] + audit_project = ci_cd_settings.project + + if ci_cd_settings.inbound_job_token_scope_enabled_previously_changed? + audit(audit_project, ci_cd_settings, current_user, ci_cd_settings.inbound_job_token_scope_enabled) + end + end + end + + private + + def audit(scope, target, author, inbound_job_token_scope_enabled) + audit_action = inbound_job_token_scope_enabled ? 'enabled' : 'disabled' + audit_message = "Secure ci_job_token was #{audit_action} for inbound" + event_name = "secure_ci_job_token_inbound_#{audit_action}" + + audit_context = { + name: event_name, + author: author, + scope: scope, + target: target, + message: audit_message + } + + ::Gitlab::Audit::Auditor.audit(audit_context) + end end end end diff --git a/ee/app/services/ee/ci/job_token_scope/add_project_service.rb b/ee/app/services/ee/ci/job_token_scope/add_project_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..2647a1eb98e44a1f1cb98359c2a50a902174eeea --- /dev/null +++ b/ee/app/services/ee/ci/job_token_scope/add_project_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module EE + module Ci + module JobTokenScope + module AddProjectService + extend ::Gitlab::Utils::Override + + override :execute + def execute(target_project, direction: :inbound) + super.tap do |response| + audit(project, target_project, current_user) if direction == :inbound && response.success? + end + end + + private + + def audit(scope, target, author) + audit_message = + "Project #{target.full_path} was added to inbound list of allowed projects for #{scope.full_path}" + event_name = 'secure_ci_job_token_project_added' + + audit_context = { + name: event_name, + author: author, + scope: scope, + target: target, + message: audit_message + } + + ::Gitlab::Audit::Auditor.audit(audit_context) + end + end + end + end +end diff --git a/ee/app/services/ee/ci/job_token_scope/remove_project_service.rb b/ee/app/services/ee/ci/job_token_scope/remove_project_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..0446740319a760b13a2739e19b394345ab816222 --- /dev/null +++ b/ee/app/services/ee/ci/job_token_scope/remove_project_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module EE + module Ci + module JobTokenScope + module RemoveProjectService + extend ::Gitlab::Utils::Override + + override :execute + def execute(target_project, direction) + super.tap do |response| + audit(project, target_project, current_user) if direction == :inbound && response.success? + end + end + + private + + def audit(scope, target, author) + audit_message = + "Project #{target.full_path} was removed from inbound list of allowed projects for #{scope.full_path}" + event_name = 'secure_ci_job_token_project_removed' + + audit_context = { + name: event_name, + author: author, + scope: scope, + target: target, + message: audit_message + } + + ::Gitlab::Audit::Auditor.audit(audit_context) + end + end + end + end +end diff --git a/ee/config/audit_events/types/secure_ci_job_token_inbound_disabled.yml b/ee/config/audit_events/types/secure_ci_job_token_inbound_disabled.yml new file mode 100644 index 0000000000000000000000000000000000000000..dc2f8100dc8dc176ac39066a8c512bce70fdf07a --- /dev/null +++ b/ee/config/audit_events/types/secure_ci_job_token_inbound_disabled.yml @@ -0,0 +1,8 @@ +name: secure_ci_job_token_inbound_disabled +description: Event triggered when CI_JOB_TOKEN permissions disabled for inbound +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/338255 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/115350 +feature_category: "verify_security" +milestone: "16.0" +saved_to_database: true +streamed: true diff --git a/ee/config/audit_events/types/secure_ci_job_token_inbound_enabled.yml b/ee/config/audit_events/types/secure_ci_job_token_inbound_enabled.yml new file mode 100644 index 0000000000000000000000000000000000000000..e8b9725b501c519fc819d9d9fffc2b3371dfafd6 --- /dev/null +++ b/ee/config/audit_events/types/secure_ci_job_token_inbound_enabled.yml @@ -0,0 +1,8 @@ +name: secure_ci_job_token_inbound_enabled +description: Event triggered when CI_JOB_TOKEN permissions enabled for inbound +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/338255 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/115350 +feature_category: "verify_security" +milestone: "16.0" +saved_to_database: true +streamed: true diff --git a/ee/config/audit_events/types/secure_ci_job_token_project_added.yml b/ee/config/audit_events/types/secure_ci_job_token_project_added.yml new file mode 100644 index 0000000000000000000000000000000000000000..f7a6c74f7d3829bc89d404765400c86733385b0e --- /dev/null +++ b/ee/config/audit_events/types/secure_ci_job_token_project_added.yml @@ -0,0 +1,8 @@ +name: secure_ci_job_token_project_added +description: Event triggered when project added to inbound CI_JOB_TOKEN scope +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/338255 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/115350 +feature_category: "verify_security" +milestone: "16.0" +saved_to_database: true +streamed: true diff --git a/ee/config/audit_events/types/secure_ci_job_token_project_removed.yml b/ee/config/audit_events/types/secure_ci_job_token_project_removed.yml new file mode 100644 index 0000000000000000000000000000000000000000..eef733bbd973bc98d05c1d474942360909f727da --- /dev/null +++ b/ee/config/audit_events/types/secure_ci_job_token_project_removed.yml @@ -0,0 +1,8 @@ +name: secure_ci_job_token_project_removed +description: Event triggered when project removed from inbound CI_JOB_TOKEN scope +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/338255 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/115350 +feature_category: "verify_security" +milestone: "16.0" +saved_to_database: true +streamed: true diff --git a/ee/spec/graphql/ee/mutations/ci/job_token_scope/add_project_spec.rb b/ee/spec/graphql/ee/mutations/ci/job_token_scope/add_project_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..eabbeeb8c0df7ff52c92799470457299dbec48cb --- /dev/null +++ b/ee/spec/graphql/ee/mutations/ci/job_token_scope/add_project_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Ci::JobTokenScope::AddProject, 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_outbound_job_token_scope_enabled: true) + end + + let_it_be(:target_project) { create(:project) } + + let(:target_project_path) { target_project.full_path } + let(:project_path) { project.full_path } + let(:mutation_args) do + { + project_path: project.full_path, + target_project_path: target_project_path, + direction: :inbound + } + end + + let(:current_user) { create(:user) } + + let(:expected_audit_context) do + { + name: event_name, + author: current_user, + scope: project, + target: target_project, + message: expected_audit_message + } + end + + subject do + mutation.resolve(**mutation_args) + end + + before do + project.add_maintainer(current_user) + target_project.add_guest(current_user) + end + + context 'when user adds target project to the inbound job token scope' do + let(:expected_audit_message) do + "Project #{target_project_path} was added to inbound list of allowed projects for #{project_path}" + end + + let(:event_name) { 'secure_ci_job_token_project_added' } + + it 'logs an audit event' do + expect(::Gitlab::Audit::Auditor).to receive(:audit).with(hash_including(expected_audit_context)) + + subject + end + + context 'and service returns an error' do + it 'does not log an audit event' do + expect_next_instance_of(::Ci::JobTokenScope::AddProjectService) do |service| + expect(service) + .to receive(:validate_edit!) + .with(project, target_project, current_user) + .and_raise(ActiveRecord::RecordNotUnique) + end + + expect(::Gitlab::Audit::Auditor).not_to receive(:audit) + + subject + end + end + end + + context 'when user adds target project to the outbound job token scope' do + let(:mutation_args) do + { project_path: project.full_path, + target_project_path: target_project_path, + direction: :outbound } + end + + it 'does not log an audit event' do + expect(::Gitlab::Audit::Auditor).not_to receive(:audit) + + subject + end + end + end +end diff --git a/ee/spec/graphql/ee/mutations/ci/job_token_scope/remove_project_spec.rb b/ee/spec/graphql/ee/mutations/ci/job_token_scope/remove_project_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..22eb3f21f7ce8681afef1e420c49fb4eaf0e1c79 --- /dev/null +++ b/ee/spec/graphql/ee/mutations/ci/job_token_scope/remove_project_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Ci::JobTokenScope::RemoveProject, 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_outbound_job_token_scope_enabled: true) + end + + let_it_be(:target_project) { create(:project) } + + let_it_be(:link) do + create(:ci_job_token_project_scope_link, + direction: :inbound, + source_project: project, + target_project: target_project) + end + + let(:links_relation) { Ci::JobToken::ProjectScopeLink.with_source(project).with_target(target_project) } + + let(:target_project_path) { target_project.full_path } + let(:project_path) { project.full_path } + let(:mutation_args) { { project_path: project.full_path, target_project_path: target_project_path } } + let(:current_user) { create(:user) } + + let(:expected_audit_context) do + { + name: event_name, + author: current_user, + scope: project, + target: target_project, + message: expected_audit_message + } + end + + subject do + mutation.resolve(**mutation_args) + end + + before do + project.add_maintainer(current_user) + target_project.add_guest(current_user) + end + + context 'when user removes target project to the inbound job token scope' do + let(:expected_audit_message) do + "Project #{target_project_path} was removed from inbound list of allowed projects for #{project_path}" + end + + let(:event_name) { 'secure_ci_job_token_project_removed' } + let(:mutation_args) do + { project_path: project.full_path, target_project_path: target_project_path, direction: :inbound } + end + + let(:service) do + instance_double('Ci::JobTokenScope::RemoveProjectService') + end + + it 'logs an audit event' do + expect(::Gitlab::Audit::Auditor).to receive(:audit).with(hash_including(expected_audit_context)) + + subject + end + + context 'and service returns an error' do + it 'does not log an audit event' do + expect(::Ci::JobTokenScope::RemoveProjectService).to receive(:new).with( + project, + current_user + ).and_return(service) + expect(service) + .to receive(:execute) + .with(target_project, :inbound) + .and_return(ServiceResponse.error(message: 'The error message')) + + expect(::Gitlab::Audit::Auditor).not_to receive(:audit) + + subject + end + end + end + + context 'when user removes target project to the default outbound job token scope' do + it 'does not log an audit event' do + expect(::Gitlab::Audit::Auditor).not_to receive(:audit) + + subject + end + end + end +end diff --git a/ee/spec/graphql/ee/mutations/ci/project_ci_cd_settings_update_spec.rb b/ee/spec/graphql/ee/mutations/ci/project_ci_cd_settings_update_spec.rb index 85a41c8e66139c38122b197e86e291abb2649737..92b4560b935bb52deb15847e79b6aad7742fc79f 100644 --- a/ee/spec/graphql/ee/mutations/ci/project_ci_cd_settings_update_spec.rb +++ b/ee/spec/graphql/ee/mutations/ci/project_ci_cd_settings_update_spec.rb @@ -17,11 +17,14 @@ stub_feature_flags(disable_merge_trains: false) project.merge_pipelines_enabled = nil project.merge_trains_enabled = false - subject - project.reload end describe '#resolve' do + before do + subject + project.reload + end + context 'when merge trains are set to true and merge pipelines are set to false' do let(:mutation_params) do { @@ -51,4 +54,93 @@ end end end + + describe 'when the inbound_job_token_scope parameter is not provided' do + let(:mutation_params) do + { + full_path: project.full_path, + merge_pipelines_enabled: true, + merge_trains_enabled: true + } + end + + it 'does not log an audit event' do + expect(::Gitlab::Audit::Auditor).not_to receive(:audit) + + subject + end + end + + describe 'inbound_job_token_scope_enabled' do + let(:mutation_params) do + { + full_path: project.full_path, + inbound_job_token_scope_enabled: inbound_job_token_scope_enabled_target + } + end + + let(:project) do + create(:project, + keep_latest_artifact: true, + ci_inbound_job_token_scope_enabled: inbound_job_token_scope_enabled_origin + ) + end + + let(:ci_cd_settings) { project.ci_cd_settings } + + let(:expected_target) do + project.ci_cd_settings + end + + let(:expected_audit_context) do + { + name: event_name, + author: user, + scope: project, + target: expected_target, + message: expected_audit_message + } + end + + before do + ci_cd_settings.update!(inbound_job_token_scope_enabled: inbound_job_token_scope_enabled_origin) + end + + context 'when changes from enabled to disabled' do + let(:inbound_job_token_scope_enabled_origin) { true } + let(:inbound_job_token_scope_enabled_target) { false } + let(:expected_audit_message) { 'Secure ci_job_token was disabled for inbound' } + let(:event_name) { 'secure_ci_job_token_inbound_disabled' } + + it 'logs an audit event' do + expect(::Gitlab::Audit::Auditor).to receive(:audit).with(hash_including(expected_audit_context)) + + subject + end + end + + context 'when changes from disabled to enabled' do + let(:inbound_job_token_scope_enabled_origin) { false } + let(:inbound_job_token_scope_enabled_target) { true } + let(:expected_audit_message) { 'Secure ci_job_token was enabled for inbound' } + let(:event_name) { 'secure_ci_job_token_inbound_enabled' } + + it 'logs an audit event' do + expect(::Gitlab::Audit::Auditor).to receive(:audit).with(hash_including(expected_audit_context)) + + subject + end + end + + context 'when there are no changes' do + let(:inbound_job_token_scope_enabled_origin) { true } + let(:inbound_job_token_scope_enabled_target) { true } + + it 'does not log an audit event' do + expect(::Gitlab::Audit::Auditor).not_to receive(:audit) + + subject + end + end + end end