diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 478ae78e8308477bdb7a4e583b66fe5717f3c079..dfaeab737e5630ba79d2185cd60b118ae35a546e 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -271,6 +271,8 @@ - 1 - - compliance_management_standards_soc2_at_least_one_non_author_approval_group - 1 +- - compliance_management_timeout_pending_external_controls + - 1 - - compliance_management_update_default_framework - 1 - - compliance_management_violation_export_mailer diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index 5c9cca7515ebb3406a2e435aa6af349fc2216b27..6edb717fe6b84f31c1f2490bc4c073dbfe08384d 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -170,6 +170,7 @@ Audit event types belong to the following product categories. | [`merge_request_merged`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/164846) | A merge request is merged | **{check-circle}** Yes | GitLab [17.5](https://gitlab.com/gitlab-org/gitlab/-/issues/442279) | Project | | [`omniauth_login_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123080) | An OmniAuth login fails | **{check-circle}** Yes | GitLab [16.3](https://gitlab.com/gitlab-org/gitlab/-/issues/374107) | User | | [`password_reset_requested`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/114548) | A user requests a password reset using a registered email address | **{check-circle}** Yes | GitLab [15.11](https://gitlab.com/gitlab-org/gitlab/-/issues/374107) | User | +| [`pending_compliance_external_control_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180016) | A project's compliance external control status is updated to fail because of timeout. | **{check-circle}** Yes | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/513421) | Project | | [`personal_access_token_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108952) | A user creates a personal access token | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/374113) | User | | [`personal_access_token_revoked`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108952) | A personal access token is revoked | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/374113) | User | | [`project_archived`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/117528) | A project is archived | **{check-circle}** Yes | GitLab [15.11](https://gitlab.com/gitlab-org/gitlab/-/issues/374105) | Project | diff --git a/ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb b/ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb index 49946bd59c73e1899cdbdbef91d2a4ec5b3ec28b..23cbca83fdd97df0298a99d8c0d71d201d639a25 100644 --- a/ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb +++ b/ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb @@ -69,6 +69,9 @@ def handle_response(response) if response.success? audit_success(response.code) + ComplianceManagement::TimeoutPendingExternalControlsWorker.perform_in(31.minutes, + { 'control_id' => control.id, 'project_id' => project.id }) + ServiceResponse.success(payload: { control: control }) else audit_error(Rack::Utils::HTTP_STATUS_CODES[response.code], response.code) diff --git a/ee/app/workers/all_queues.yml b/ee/app/workers/all_queues.yml index b06705bb85565c5cbe991ac52243c4be748cfe0a..55428d16a55a46305b0ee0a71736f2c36a30b675 100644 --- a/ee/app/workers/all_queues.yml +++ b/ee/app/workers/all_queues.yml @@ -1693,6 +1693,16 @@ :idempotent: true :tags: [] :queue_namespace: +- :name: compliance_management_timeout_pending_external_controls + :worker_name: ComplianceManagement::TimeoutPendingExternalControlsWorker + :feature_category: :compliance_management + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] + :queue_namespace: - :name: compliance_management_update_default_framework :worker_name: ComplianceManagement::UpdateDefaultFrameworkWorker :feature_category: :compliance_management diff --git a/ee/app/workers/compliance_management/timeout_pending_external_controls_worker.rb b/ee/app/workers/compliance_management/timeout_pending_external_controls_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..cab206845c7414abecf6c4190ae62d7f0e07cd59 --- /dev/null +++ b/ee/app/workers/compliance_management/timeout_pending_external_controls_worker.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module ComplianceManagement + class TimeoutPendingExternalControlsWorker + include ApplicationWorker + + idempotent! + feature_category :compliance_management + data_consistency :sticky + urgency :low + + PENDING_STATUS_TIMEOUT = 30.minutes + + def perform(args = {}) + @args = args.with_indifferent_access + + return unless valid_control? + return unless valid_project_control_compliance_status? + return unless timeout_project_control_compliance_status? + + @project_control_compliance_status.fail! + + create_audit_log + end + + private + + def valid_control? + control_id = @args[:control_id] + @control = ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.find_by_id(control_id) + @control.present? + end + + def valid_project_control_compliance_status? + control_id, project_id = @args.values_at(:control_id, :project_id) + @project_control_compliance_status = ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus + .for_project_and_control(project_id, control_id).last + + @project_control_compliance_status.present? + end + + def timeout_project_control_compliance_status? + @project_control_compliance_status.pending? && + @project_control_compliance_status.updated_at < PENDING_STATUS_TIMEOUT.ago + end + + def create_audit_log + audit_context = { + name: 'pending_compliance_external_control_failed', + author: ::Gitlab::Audit::UnauthenticatedAuthor.new(name: '(System)'), + scope: @project_control_compliance_status.project, + target: @project_control_compliance_status.project, + message: "Project control compliance status with URL #{@control.external_url} marked as fail." + } + + ::Gitlab::Audit::Auditor.audit(audit_context) + end + end +end diff --git a/ee/config/audit_events/types/pending_compliance_external_control_failed.yml b/ee/config/audit_events/types/pending_compliance_external_control_failed.yml new file mode 100644 index 0000000000000000000000000000000000000000..5ef423c472102aa29433e8b3ea6240a687bad87a --- /dev/null +++ b/ee/config/audit_events/types/pending_compliance_external_control_failed.yml @@ -0,0 +1,10 @@ +--- +name: pending_compliance_external_control_failed +description: A project's compliance external control status is updated to fail because of timeout. +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/513421 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180016 +feature_category: compliance_management +milestone: '17.9' +saved_to_database: true +streamed: true +scope: [Project] diff --git a/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb index a31096e0f5c973974a8d9dfd64dc374e30ad1487..4cf4fc42295d67b43ea6a3047a4fcc078f4dfd9e 100644 --- a/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb +++ b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb @@ -111,6 +111,13 @@ end end + it 'schedules a timeout worker' do + expect(ComplianceManagement::TimeoutPendingExternalControlsWorker).to receive(:perform_in) + .with(31.minutes, { 'control_id' => control.id, 'project_id' => project.id }) + + service.execute + end + it 'creates an audit event for successful request' do expect(Gitlab::Audit::Auditor).to receive(:audit).with( hash_including( diff --git a/ee/spec/workers/compliance_management/timeout_pending_external_controls_worker_spec.rb b/ee/spec/workers/compliance_management/timeout_pending_external_controls_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9f05980beea2b8414ff8baca178ad71862f8f9bc --- /dev/null +++ b/ee/spec/workers/compliance_management/timeout_pending_external_controls_worker_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ComplianceManagement::TimeoutPendingExternalControlsWorker, feature_category: :compliance_management do + let_it_be(:project) { create(:project) } + let_it_be(:control) { create(:compliance_requirements_control, :external) } + + let(:worker) { described_class.new } + let(:args) { { control_id: control.id, project_id: project.id } } + + describe '#perform' do + context 'when control does not exist' do + let(:args) { { control_id: non_existing_record_id, project_id: project.id } } + + it 'does nothing' do + expect(ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus) + .not_to receive(:for_project_and_control) + + worker.perform(args) + end + end + + context 'when compliance status does not exist' do + it 'does nothing' do + expect(::Gitlab::Audit::Auditor).not_to receive(:audit) + + worker.perform(args) + end + end + + context 'when compliance status exists' do + let_it_be(:compliance_status) do + create(:project_control_compliance_status, + project: project, + compliance_requirements_control: control, + status: :pending + ) + end + + context 'when status is not pending' do + before do + compliance_status.update!(status: :fail) + end + + it 'does nothing' do + expect(compliance_status).not_to receive(:fail!) + + worker.perform(args) + end + end + + context 'when status was updated less than 30 minutes ago' do + before do + compliance_status.touch + end + + it 'does nothing' do + expect(compliance_status).not_to receive(:fail!) + + worker.perform(args) + end + end + + context 'when status is pending and was updated more than 30 minutes ago' do + before do + compliance_status.update!(updated_at: 31.minutes.ago) + end + + it 'marks status as failed' do + expect(compliance_status.reload.status).to eq("pending") + + worker.perform(args) + + expect(compliance_status.reload.status).to eq("fail") + end + + it 'creates an audit event' do + expected_message = "Project control compliance status with URL #{control.external_url} marked as fail." + + expect(::Gitlab::Audit::Auditor).to receive(:audit).with( + hash_including( + name: 'pending_compliance_external_control_failed', + author: instance_of(::Gitlab::Audit::UnauthenticatedAuthor), + scope: project, + target: project, + message: expected_message + ) + ) + + worker.perform(args) + end + end + end + + context 'when args are strings' do + let(:string_args) { { 'control_id' => control.id.to_s, 'project_id' => project.id.to_s } } + + it 'handles string keys' do + expect(ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl) + .to receive(:find_by_id).with(control.id.to_s).and_return(control) + + worker.perform(string_args) + end + end + end + + it_behaves_like 'an idempotent worker' do + let(:job_args) { { control_id: control.id, project_id: project.id } } + end +end