From 4627161d4f41ec86ce84ae20c6aaefe5657a0e1c Mon Sep 17 00:00:00 2001 From: huzaifaiftikhar1 Date: Mon, 3 Feb 2025 16:00:26 +0530 Subject: [PATCH 1/5] Add service class to trigger external control Changelog: added EE: true --- app/serializers/project_serializer.rb | 2 + doc/user/compliance/audit_event_types.md | 2 + .../compliance_requirements_control.rb | 8 +- .../project_control_compliance_status.rb | 4 + .../trigger_external_control_service.rb | 105 +++++++++ ..._to_compliance_external_control_failed.yml | 10 + ...compliance_external_control_successful.yml | 10 + .../compliance_requirements_control_spec.rb | 3 +- .../project_control_compliance_status_spec.rb | 54 +++++ .../trigger_external_control_service_spec.rb | 223 ++++++++++++++++++ spec/serializers/project_serializer_spec.rb | 18 ++ 11 files changed, 435 insertions(+), 4 deletions(-) create mode 100644 ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb create mode 100644 ee/config/audit_events/types/request_to_compliance_external_control_failed.yml create mode 100644 ee/config/audit_events/types/request_to_compliance_external_control_successful.yml create mode 100644 ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb index 52ac2fa0e0987f..086e6c5dd3325b 100644 --- a/app/serializers/project_serializer.rb +++ b/app/serializers/project_serializer.rb @@ -6,6 +6,8 @@ def represent(project, opts = {}) case opts[:serializer] when :import ProjectImportEntity + when :project_details + API::Entities::Project else ProjectEntity end diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index bd17586b288770..a4b46f227c1af1 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -191,6 +191,8 @@ Audit event types belong to the following product categories. | [`release_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111080) | A release is updated | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/374111) | Project | | [`remove_gpg_key`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111744) | A GPG key is deleted | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/373961) | User | | [`repository_download_operation`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111218) | A Git repository for a private or internal project is downloaded | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/374108) | Project | +| [`request_to_compliance_external_control_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010) | Request to compliance requirement external control fails | **{check-circle}** Yes | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/513421) | Project | +| [`request_to_compliance_external_control_successful`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010) | Request to compliance requirement external control succeeds | **{dotted-circle}** No | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/513421) | Project | | [`require_password_to_approve_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102256) | Require user password for approvals from group merge request setting is updated | **{check-circle}** Yes | GitLab [15.6](https://gitlab.com/gitlab-org/gitlab/-/issues/373949) | Group | | [`retain_approvals_on_push_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102256) | Require new approvals when new commits are added to an MR from group merge request setting is updated | **{check-circle}** Yes | GitLab [15.6](https://gitlab.com/gitlab-org/gitlab/-/issues/373949) | Group | | [`saml_group_links_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/110525) | A SAML Group Link is created | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/373954) | Group | diff --git a/ee/app/models/compliance_management/compliance_framework/compliance_requirements_control.rb b/ee/app/models/compliance_management/compliance_framework/compliance_requirements_control.rb index f6ac67051c1f03..2e2db7602fcfe8 100644 --- a/ee/app/models/compliance_management/compliance_framework/compliance_requirements_control.rb +++ b/ee/app/models/compliance_management/compliance_framework/compliance_requirements_control.rb @@ -28,7 +28,8 @@ class ComplianceRequirementsControl < ApplicationRecord merge_request_prevent_author_approval: 2, merge_request_prevent_committers_approval: 3, project_visibility_not_internal: 4, - default_branch_protected: 5 + default_branch_protected: 5, + external_control: 10000 } enum control_type: { @@ -43,8 +44,9 @@ class ComplianceRequirementsControl < ApplicationRecord validate :validate_internal_expression, if: :internal? validate :controls_count_per_requirement - validates :external_url, presence: true, addressable_url: true, if: :external? - validates :name, uniqueness: { scope: :compliance_requirement_id } + validates :external_url, presence: true, addressable_url: true, + uniqueness: { scope: :compliance_requirement_id }, if: :external? + validates :name, uniqueness: { scope: :compliance_requirement_id }, if: :internal? validates :secret_token, presence: true, if: :external? private diff --git a/ee/app/models/compliance_management/compliance_framework/project_control_compliance_status.rb b/ee/app/models/compliance_management/compliance_framework/project_control_compliance_status.rb index ae5c9ee1be6cf7..c0b2ea27c5d444 100644 --- a/ee/app/models/compliance_management/compliance_framework/project_control_compliance_status.rb +++ b/ee/app/models/compliance_management/compliance_framework/project_control_compliance_status.rb @@ -17,6 +17,10 @@ class ProjectControlComplianceStatus < ApplicationRecord validates :project_id, uniqueness: { scope: :compliance_requirements_control_id } validates_presence_of :status, :project, :namespace, :compliance_requirement, :compliance_requirements_control + + scope :for_project_and_control, ->(project_id, control_id) { + where(project_id: project_id, compliance_requirements_control_id: control_id) + } end end end 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 new file mode 100644 index 00000000000000..13f5e3eb95d8f9 --- /dev/null +++ b/ee/app/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module ComplianceManagement + module ComplianceFramework + module ComplianceRequirements + class TriggerExternalControlService < BaseProjectService + attr_accessor :control + + def initialize(project, control) + @control = control + + super(project: project) + end + + def execute + return unless control.external? + + project_control_compliance_status = create_or_find_project_control_compliance_status + + project_control_compliance_status.pending! + + # rubocop: disable CodeReuse/Serializer -- We serialize project and send the payload to the user's external service. + # This is performed through a background job and therefore we cannot use controllers. + project_data = ::ProjectSerializer.new.represent(project, serializer: :project_details) + # rubocop: enable CodeReuse/Serializer + + body = Gitlab::Json::LimitedEncoder.encode(project_data) + headers = { 'Content-Type' => 'application/json' } + headers['X-GitLab-Signature'] = OpenSSL::HMAC.hexdigest('sha256', control.secret_token, body) + + response = Gitlab::HTTP.post(control.external_url, headers: headers, body: body) + + if response.success? + audit_success(response.code) + + ServiceResponse.success(payload: { control: control }) + else + audit_error(Rack::Utils::HTTP_STATUS_CODES[response.code], response.code) + + ServiceResponse.error(message: "External control service responded with an error. HTTP #{response.code}", + reason: Rack::Utils::HTTP_STATUS_CODES[response.code]) + end + rescue ActiveRecord::RecordInvalid => invalid + # Handle race condition if two instances of this service were executed at the same time. + # In such cases both of them might not find records, however, one of them will error out while creating. + retry if invalid.record&.errors&.of_kind?(:project, :taken) + + ServiceResponse.error(message: invalid.record&.errors&.full_messages&.join(', ')) + rescue *::Gitlab::HTTP_V2::HTTP_ERRORS => e + audit_error(e.message) + ServiceResponse.error(message: e.message, reason: :network_error) + end + + private + + def create_or_find_project_control_compliance_status + ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus + .for_project_and_control(project.id, control.id).last || + ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.create!(attributes) + end + + def attributes + { + compliance_requirements_control_id: control.id, + project_id: project.id, + namespace_id: project.namespace_id, + compliance_requirement_id: control.compliance_requirement.id, + status: :pending + } + end + + def audit_success(http_code) + message = "Request to compliance requirement external control with URL #{control.external_url} successful." + message += " HTTP #{http_code}" + + audit_context = { + name: 'request_to_compliance_external_control_successful', + author: ::Gitlab::Audit::UnauthenticatedAuthor.new(name: '(System)'), + scope: project, + target: project, + message: message + } + + ::Gitlab::Audit::Auditor.audit(audit_context) + end + + def audit_error(error_message, http_code = nil) + message = "Request to compliance requirement external control with URL #{control.external_url} failed." + message += " HTTP #{http_code}" if http_code + message += " #{error_message}" + + audit_context = { + name: 'request_to_compliance_external_control_failed', + author: ::Gitlab::Audit::UnauthenticatedAuthor.new(name: '(System)'), + scope: project, + target: project, + message: message.strip.truncate(1000) + } + + ::Gitlab::Audit::Auditor.audit(audit_context) + end + end + end + end +end diff --git a/ee/config/audit_events/types/request_to_compliance_external_control_failed.yml b/ee/config/audit_events/types/request_to_compliance_external_control_failed.yml new file mode 100644 index 00000000000000..2427fd945c0fe4 --- /dev/null +++ b/ee/config/audit_events/types/request_to_compliance_external_control_failed.yml @@ -0,0 +1,10 @@ +--- +name: request_to_compliance_external_control_failed +description: Request to compliance requirement external control fails +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/513421 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010 +feature_category: compliance_management +milestone: '17.9' +saved_to_database: true +streamed: true +scope: [Project] diff --git a/ee/config/audit_events/types/request_to_compliance_external_control_successful.yml b/ee/config/audit_events/types/request_to_compliance_external_control_successful.yml new file mode 100644 index 00000000000000..f46e5c333a3972 --- /dev/null +++ b/ee/config/audit_events/types/request_to_compliance_external_control_successful.yml @@ -0,0 +1,10 @@ +--- +name: request_to_compliance_external_control_successful +description: Request to compliance requirement external control succeeds +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/513421 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010 +feature_category: compliance_management +milestone: '17.9' +saved_to_database: false +streamed: true +scope: [Project] diff --git a/ee/spec/models/compliance_management/compliance_framework/compliance_requirements_control_spec.rb b/ee/spec/models/compliance_management/compliance_framework/compliance_requirements_control_spec.rb index 8f0ef0fe7f54a5..f38abab0bbdae2 100644 --- a/ee/spec/models/compliance_management/compliance_framework/compliance_requirements_control_spec.rb +++ b/ee/spec/models/compliance_management/compliance_framework/compliance_requirements_control_spec.rb @@ -38,7 +38,8 @@ merge_request_prevent_author_approval: 2, merge_request_prevent_committers_approval: 3, project_visibility_not_internal: 4, - default_branch_protected: 5 + default_branch_protected: 5, + external_control: 10000 ) end diff --git a/ee/spec/models/compliance_management/compliance_framework/project_control_compliance_status_spec.rb b/ee/spec/models/compliance_management/compliance_framework/project_control_compliance_status_spec.rb index a056d9b6c12df8..af92a9d5db60e9 100644 --- a/ee/spec/models/compliance_management/compliance_framework/project_control_compliance_status_spec.rb +++ b/ee/spec/models/compliance_management/compliance_framework/project_control_compliance_status_spec.rb @@ -40,4 +40,58 @@ expect(status).to be_valid end end + + describe '.for_project_and_control' do + let_it_be(:project) { create(:project) } + let_it_be(:another_project) { create(:project) } + let_it_be(:control) { create(:compliance_requirements_control) } + let_it_be(:another_control) { create(:compliance_requirements_control) } + + let_it_be(:status) do + create(:project_control_compliance_status, + project: project, + compliance_requirements_control: control + ) + end + + let_it_be(:another_project_status) do + create(:project_control_compliance_status, + project: another_project, + compliance_requirements_control: control + ) + end + + let_it_be(:another_control_status) do + create(:project_control_compliance_status, + project: project, + compliance_requirements_control: another_control + ) + end + + it 'returns records matching project_id and control_id' do + result = described_class.for_project_and_control(project.id, control.id) + + expect(result).to contain_exactly(status) + end + + it 'returns empty when no matching records exist' do + result = described_class.for_project_and_control(non_existing_record_id, non_existing_record_id) + + expect(result).to be_empty + end + + it 'does not return records for different project' do + result = described_class.for_project_and_control(another_project.id, control.id) + + expect(result).not_to include(status) + expect(result).to contain_exactly(another_project_status) + end + + it 'does not return records for different control' do + result = described_class.for_project_and_control(project.id, another_control.id) + + expect(result).not_to include(status) + expect(result).to contain_exactly(another_control_status) + end + end end 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 new file mode 100644 index 00000000000000..af92d1c088a279 --- /dev/null +++ b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/trigger_external_control_service_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ComplianceManagement::ComplianceFramework::ComplianceRequirements::TriggerExternalControlService, + feature_category: :compliance_management do + let_it_be(:project) { create(:project) } + let_it_be(:compliance_requirement) { create(:compliance_requirement) } + let_it_be(:control) do + create(:compliance_requirements_control, :external, compliance_requirement: compliance_requirement) + end + + subject(:service) { described_class.new(project, control) } + + describe '#execute' do + context 'when control is not external' do + before do + allow(control).to receive(:external?).and_return(false) + end + + it 'returns nil' do + expect(service.execute).to be_nil + end + end + + context 'when control is external' do + let_it_be(:project_data) { { id: project.id, name: project.name } } + let_it_be(:encoded_data) { Gitlab::Json::LimitedEncoder.encode(project_data) } + + before do + allow(control).to receive(:external?).and_return(true) + allow(ProjectSerializer).to receive_message_chain(:new, :represent).and_return(project_data) + end + + context 'with successful HTTP request' do + let(:success_response) { instance_double(Gitlab::HTTP::Response, success?: true, code: 200) } + + before do + allow(Gitlab::HTTP).to receive(:post).and_return(success_response) + end + + it 'makes a POST request to the external URL with correct data' do + expected_hmac = OpenSSL::HMAC.hexdigest('sha256', control.secret_token, encoded_data) + expected_headers = { + 'Content-Type' => 'application/json', + 'X-GitLab-Signature' => expected_hmac + } + + expect(Gitlab::HTTP).to receive(:post).with( + control.external_url, + headers: expected_headers, + body: encoded_data + ).and_return(success_response) + + service.execute + end + + context 'when compliance status does not exist' do + it 'creates a new compliance status record' do + expect { service.execute }.to change { + ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.count + }.by(1) + end + + it 'creates the record with pending status' do + service.execute + status = ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.last + + expect(status.status).to eq('pending') + end + end + + context 'when compliance status already exists' do + let!(:existing_status) do + ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.create!( + compliance_requirements_control_id: control.id, + project_id: project.id, + namespace_id: project.namespace_id, + compliance_requirement_id: compliance_requirement.id, + status: :fail + ) + end + + it 'does not create a new compliance status record' do + expect { service.execute }.not_to change { + ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.count + } + end + + it 'updates the existing status to pending' do + service.execute + + expect(existing_status.reload.status).to eq('pending') + end + end + + it 'creates an audit event for successful request' do + expect(Gitlab::Audit::Auditor).to receive(:audit).with( + hash_including( + name: 'request_to_compliance_external_control_successful', + scope: project, + target: project + ) + ) + + service.execute + end + + it 'returns a successful service response' do + response = service.execute + + expect(response).to be_success + expect(response.payload).to eq({ control: control }) + end + end + + context 'with failed HTTP request' do + let(:error_response) { instance_double(Gitlab::HTTP::Response, success?: false, code: 500) } + + before do + allow(Gitlab::HTTP).to receive(:post).and_return(error_response) + end + + it 'creates an audit event for failed request' do + expect(Gitlab::Audit::Auditor).to receive(:audit).with( + hash_including( + name: 'request_to_compliance_external_control_failed', + scope: project, + target: project + ) + ) + + service.execute + end + + it 'returns an error service response' do + response = service.execute + + expect(response).not_to be_success + expect(response.message).to include('External control service responded with an error') + expect(response.reason).to eq('Internal Server Error') + end + end + + context 'with network error' do + before do + allow(Gitlab::HTTP).to receive(:post).and_raise(Errno::ECONNRESET) + end + + it 'creates an audit event for the error' do + expect(Gitlab::Audit::Auditor).to receive(:audit).with( + hash_including( + name: 'request_to_compliance_external_control_failed', + scope: project, + target: project + ) + ) + + service.execute + end + + it 'returns an error service response' do + response = service.execute + + expect(response).not_to be_success + expect(response.message).to eq('Connection reset by peer') + expect(response.reason).to eq(:network_error) + end + end + + context 'when ActiveRecord::RecordInvalid is raised' do + let(:success_response) { instance_double(HTTParty::Response, success?: true, code: 200) } + + before do + allow(Gitlab::HTTP).to receive(:post).and_return(success_response) + end + + it 'retries in case of race conditions' do + record_invalid_error = ActiveRecord::RecordInvalid.new( + build(:project_control_compliance_status).tap do |control_status| + control_status.errors.add(:project, :taken, message: "project taken") + end + ) + + expect_next_instance_of(::ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus) do |cs| + expect(cs).to receive(:save!).and_raise(record_invalid_error) + end + + allow_next_instance_of(::ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus) do |cs| + allow(cs).to receive(:save!).and_call_original + end + + response = service.execute + + expect(response.status).to eq(:success) + expect(project.project_control_compliance_statuses.last) + .to have_attributes( + project_id: project.id, + namespace_id: project.namespace_id, + compliance_requirements_control_id: control.id, + compliance_requirement_id: control.compliance_requirement.id, + status: 'pending' + ) + end + + it 'does not retry for other scenarios' do + record_invalid_error = ActiveRecord::RecordInvalid.new( + build(:project_control_compliance_status).tap { |status| status.errors.add(:status, :blank) } + ) + + expect_next_instance_of(::ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus) do |cs| + expect(cs).to receive(:save!).and_raise(record_invalid_error) + end + + response = service.execute + + expect(response.status).to eq(:error) + expect(response.message).to eq("Status can't be blank") + end + end + end + end +end diff --git a/spec/serializers/project_serializer_spec.rb b/spec/serializers/project_serializer_spec.rb index 317a3714f0c706..8ad16e60da2c96 100644 --- a/spec/serializers/project_serializer_spec.rb +++ b/spec/serializers/project_serializer_spec.rb @@ -25,6 +25,24 @@ end end + context 'when serializer option is :project_details' do + subject do + described_class.new.represent(project, serializer: :project_details) + end + + before do + allow(API::Entities::Project).to receive(:represent) + end + + it 'represents with API::Entities::Project' do + subject + + expect(API::Entities::Project) + .to have_received(:represent) + .with(project, serializer: :project_details, request: an_instance_of(EntityRequest)) + end + end + context 'when serializer option is omitted' do subject do described_class.new.represent(project) -- GitLab From f3214fd8d7ec0c37577010748fae62d5c9b8aa17 Mon Sep 17 00:00:00 2001 From: huzaifaiftikhar1 Date: Wed, 5 Feb 2025 20:14:46 +0530 Subject: [PATCH 2/5] Update RSpecs for trigger_external_control_service --- .../project_control_compliance_status_spec.rb | 7 ------- .../trigger_external_control_service_spec.rb | 3 +-- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/ee/spec/models/compliance_management/compliance_framework/project_control_compliance_status_spec.rb b/ee/spec/models/compliance_management/compliance_framework/project_control_compliance_status_spec.rb index af92a9d5db60e9..f0ae6bec404e70 100644 --- a/ee/spec/models/compliance_management/compliance_framework/project_control_compliance_status_spec.rb +++ b/ee/spec/models/compliance_management/compliance_framework/project_control_compliance_status_spec.rb @@ -86,12 +86,5 @@ expect(result).not_to include(status) expect(result).to contain_exactly(another_project_status) end - - it 'does not return records for different control' do - result = described_class.for_project_and_control(project.id, another_control.id) - - expect(result).not_to include(status) - expect(result).to contain_exactly(another_control_status) - end end end 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 af92d1c088a279..e80906c8e9800f 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 @@ -24,12 +24,11 @@ end context 'when control is external' do - let_it_be(:project_data) { { id: project.id, name: project.name } } + let_it_be(:project_data) { ProjectSerializer.new.represent(project, serializer: :project_details) } let_it_be(:encoded_data) { Gitlab::Json::LimitedEncoder.encode(project_data) } before do allow(control).to receive(:external?).and_return(true) - allow(ProjectSerializer).to receive_message_chain(:new, :represent).and_return(project_data) end context 'with successful HTTP request' do -- GitLab From 92c4c41b36a70cabc897e7d74c5042f82d1e98fc Mon Sep 17 00:00:00 2001 From: huzaifaiftikhar1 Date: Thu, 6 Feb 2025 17:12:00 +0530 Subject: [PATCH 3/5] Send project_control_compliance_status as payload to the external URL --- .../trigger_external_control_service.rb | 2 + .../trigger_external_control_service_spec.rb | 54 ++++++++++++------- 2 files changed, 38 insertions(+), 18 deletions(-) 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 13f5e3eb95d8f9..5476909f0e64df 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 @@ -24,6 +24,8 @@ def execute project_data = ::ProjectSerializer.new.represent(project, serializer: :project_details) # rubocop: enable CodeReuse/Serializer + project_data[:project_control_compliance_status] = project_control_compliance_status.as_json + body = Gitlab::Json::LimitedEncoder.encode(project_data) headers = { 'Content-Type' => 'application/json' } headers['X-GitLab-Signature'] = OpenSSL::HMAC.hexdigest('sha256', control.secret_token, body) 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 e80906c8e9800f..a193802da684e5 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 @@ -24,14 +24,21 @@ end context 'when control is external' do - let_it_be(:project_data) { ProjectSerializer.new.represent(project, serializer: :project_details) } - let_it_be(:encoded_data) { Gitlab::Json::LimitedEncoder.encode(project_data) } - before do allow(control).to receive(:external?).and_return(true) end context 'with successful HTTP request' do + let!(:existing_status) do + create(:project_control_compliance_status, + project: project, + compliance_requirements_control: control, + namespace_id: project.namespace_id, + compliance_requirement_id: control.compliance_requirement.id, + status: :pending + ) + end + let(:success_response) { instance_double(Gitlab::HTTP::Response, success?: true, code: 200) } before do @@ -39,22 +46,33 @@ end it 'makes a POST request to the external URL with correct data' do - expected_hmac = OpenSSL::HMAC.hexdigest('sha256', control.secret_token, encoded_data) - expected_headers = { - 'Content-Type' => 'application/json', - 'X-GitLab-Signature' => expected_hmac - } - - expect(Gitlab::HTTP).to receive(:post).with( - control.external_url, - headers: expected_headers, - body: encoded_data - ).and_return(success_response) + project_control_compliance_status = ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus + .for_project_and_control(project.id, control.id).last + + project_data = ProjectSerializer.new.represent(project, serializer: :project_details) + project_data[:project_control_compliance_status] = project_control_compliance_status.as_json + encoded_data = Gitlab::Json::LimitedEncoder.encode(project_data) + + expect(Gitlab::HTTP).to receive(:post) do |url, params| + expect(url).to eq(control.external_url) + expect(params[:headers]['Content-Type']).to eq('application/json') + + actual_json = ::Gitlab::Json.parse(params[:body]) + expected_json = ::Gitlab::Json.parse(encoded_data) + expect(actual_json).to eq(expected_json) + + expected_hmac = OpenSSL::HMAC.hexdigest('sha256', control.secret_token, params[:body]) + expect(params[:headers]['X-GitLab-Signature']).to eq(expected_hmac) + end.and_return(success_response) service.execute end context 'when compliance status does not exist' do + before do + ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.delete_all + end + it 'creates a new compliance status record' do expect { service.execute }.to change { ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.count @@ -71,11 +89,11 @@ context 'when compliance status already exists' do let!(:existing_status) do - ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.create!( - compliance_requirements_control_id: control.id, - project_id: project.id, + create(:project_control_compliance_status, + project: project, + compliance_requirements_control: control, namespace_id: project.namespace_id, - compliance_requirement_id: compliance_requirement.id, + compliance_requirement_id: control.compliance_requirement.id, status: :fail ) end -- GitLab From f8c3c7f0f6525f0802c78449bb35bf029cedbba3 Mon Sep 17 00:00:00 2001 From: huzaifaiftikhar1 Date: Thu, 6 Feb 2025 17:30:50 +0530 Subject: [PATCH 4/5] Simplify execute method --- .../trigger_external_control_service.rb | 78 ++++++++++++------- .../trigger_external_control_service_spec.rb | 6 +- 2 files changed, 52 insertions(+), 32 deletions(-) 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 5476909f0e64df..49946bd59c73e1 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 @@ -15,23 +15,57 @@ def initialize(project, control) def execute return unless control.external? - project_control_compliance_status = create_or_find_project_control_compliance_status + mark_compliance_status_pending! + response = send_external_request + handle_response(response) + rescue ActiveRecord::RecordInvalid => invalid + retry if should_retry?(invalid) + + ServiceResponse.error(message: invalid.record&.errors&.full_messages&.join(', ')) + rescue *::Gitlab::HTTP_V2::HTTP_ERRORS => e + handle_http_error(e) + end + + private + + def mark_compliance_status_pending! + @project_control_compliance_status = create_or_find_project_control_compliance_status + + @project_control_compliance_status.pending! + end + + def create_or_find_project_control_compliance_status + ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus + .for_project_and_control(project.id, control.id).last || + ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.create!(attributes) + end - project_control_compliance_status.pending! + def attributes + { + compliance_requirements_control_id: control.id, + project_id: project.id, + namespace_id: project.namespace_id, + compliance_requirement_id: control.compliance_requirement.id, + status: :pending + } + end + def send_external_request # rubocop: disable CodeReuse/Serializer -- We serialize project and send the payload to the user's external service. # This is performed through a background job and therefore we cannot use controllers. project_data = ::ProjectSerializer.new.represent(project, serializer: :project_details) # rubocop: enable CodeReuse/Serializer - project_data[:project_control_compliance_status] = project_control_compliance_status.as_json + project_data[:project_control_compliance_status] = @project_control_compliance_status.as_json body = Gitlab::Json::LimitedEncoder.encode(project_data) headers = { 'Content-Type' => 'application/json' } headers['X-GitLab-Signature'] = OpenSSL::HMAC.hexdigest('sha256', control.secret_token, body) - response = Gitlab::HTTP.post(control.external_url, headers: headers, body: body) + Gitlab::HTTP.post(control.external_url, headers: headers, body: body) + end + def handle_response(response) if response.success? audit_success(response.code) @@ -42,33 +76,17 @@ def execute ServiceResponse.error(message: "External control service responded with an error. HTTP #{response.code}", reason: Rack::Utils::HTTP_STATUS_CODES[response.code]) end - rescue ActiveRecord::RecordInvalid => invalid - # Handle race condition if two instances of this service were executed at the same time. - # In such cases both of them might not find records, however, one of them will error out while creating. - retry if invalid.record&.errors&.of_kind?(:project, :taken) - - ServiceResponse.error(message: invalid.record&.errors&.full_messages&.join(', ')) - rescue *::Gitlab::HTTP_V2::HTTP_ERRORS => e - audit_error(e.message) - ServiceResponse.error(message: e.message, reason: :network_error) end - private - - def create_or_find_project_control_compliance_status - ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus - .for_project_and_control(project.id, control.id).last || - ComplianceManagement::ComplianceFramework::ProjectControlComplianceStatus.create!(attributes) + def handle_http_error(err) + audit_error(err.message) + ServiceResponse.error(message: err.message, reason: :network_error) end - def attributes - { - compliance_requirements_control_id: control.id, - project_id: project.id, - namespace_id: project.namespace_id, - compliance_requirement_id: control.compliance_requirement.id, - status: :pending - } + def should_retry?(invalid) + # Handle race condition if two instances of this service were executed at the same time. + # In such cases both of them might not find records, however, one of them will error out while creating. + invalid.record&.errors&.of_kind?(:project, :taken) end def audit_success(http_code) @@ -79,7 +97,8 @@ def audit_success(http_code) name: 'request_to_compliance_external_control_successful', author: ::Gitlab::Audit::UnauthenticatedAuthor.new(name: '(System)'), scope: project, - target: project, + target: control, + target_details: "External control", message: message } @@ -95,7 +114,8 @@ def audit_error(error_message, http_code = nil) name: 'request_to_compliance_external_control_failed', author: ::Gitlab::Audit::UnauthenticatedAuthor.new(name: '(System)'), scope: project, - target: project, + target: control, + target_details: "External control", message: message.strip.truncate(1000) } 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 a193802da684e5..a31096e0f5c973 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 @@ -116,7 +116,7 @@ hash_including( name: 'request_to_compliance_external_control_successful', scope: project, - target: project + target: control ) ) @@ -143,7 +143,7 @@ hash_including( name: 'request_to_compliance_external_control_failed', scope: project, - target: project + target: control ) ) @@ -169,7 +169,7 @@ hash_including( name: 'request_to_compliance_external_control_failed', scope: project, - target: project + target: control ) ) -- GitLab From 4eb20c758ad0e5e3e237aedb31077aab72f59e0a Mon Sep 17 00:00:00 2001 From: huzaifaiftikhar1 Date: Fri, 7 Feb 2025 10:56:46 +0530 Subject: [PATCH 5/5] Update description of compliance external control audit events --- doc/user/compliance/audit_event_types.md | 4 ++-- .../types/request_to_compliance_external_control_failed.yml | 2 +- .../request_to_compliance_external_control_successful.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index a4b46f227c1af1..d5199d7fc4076e 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -191,8 +191,8 @@ Audit event types belong to the following product categories. | [`release_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111080) | A release is updated | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/374111) | Project | | [`remove_gpg_key`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111744) | A GPG key is deleted | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/373961) | User | | [`repository_download_operation`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111218) | A Git repository for a private or internal project is downloaded | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/374108) | Project | -| [`request_to_compliance_external_control_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010) | Request to compliance requirement external control fails | **{check-circle}** Yes | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/513421) | Project | -| [`request_to_compliance_external_control_successful`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010) | Request to compliance requirement external control succeeds | **{dotted-circle}** No | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/513421) | Project | +| [`request_to_compliance_external_control_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010) | Request to compliance external control failed | **{check-circle}** Yes | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/513421) | Project | +| [`request_to_compliance_external_control_successful`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010) | Request to compliance external control successful | **{dotted-circle}** No | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/513421) | Project | | [`require_password_to_approve_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102256) | Require user password for approvals from group merge request setting is updated | **{check-circle}** Yes | GitLab [15.6](https://gitlab.com/gitlab-org/gitlab/-/issues/373949) | Group | | [`retain_approvals_on_push_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/102256) | Require new approvals when new commits are added to an MR from group merge request setting is updated | **{check-circle}** Yes | GitLab [15.6](https://gitlab.com/gitlab-org/gitlab/-/issues/373949) | Group | | [`saml_group_links_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/110525) | A SAML Group Link is created | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/373954) | Group | diff --git a/ee/config/audit_events/types/request_to_compliance_external_control_failed.yml b/ee/config/audit_events/types/request_to_compliance_external_control_failed.yml index 2427fd945c0fe4..8372727386559b 100644 --- a/ee/config/audit_events/types/request_to_compliance_external_control_failed.yml +++ b/ee/config/audit_events/types/request_to_compliance_external_control_failed.yml @@ -1,6 +1,6 @@ --- name: request_to_compliance_external_control_failed -description: Request to compliance requirement external control fails +description: Request to compliance external control failed introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/513421 introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010 feature_category: compliance_management diff --git a/ee/config/audit_events/types/request_to_compliance_external_control_successful.yml b/ee/config/audit_events/types/request_to_compliance_external_control_successful.yml index f46e5c333a3972..a14ea3660acfbe 100644 --- a/ee/config/audit_events/types/request_to_compliance_external_control_successful.yml +++ b/ee/config/audit_events/types/request_to_compliance_external_control_successful.yml @@ -1,6 +1,6 @@ --- name: request_to_compliance_external_control_successful -description: Request to compliance requirement external control succeeds +description: Request to compliance external control successful introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/513421 introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/180010 feature_category: compliance_management -- GitLab