diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 05a118d40d1fd63602b54cc8955f2c5e659ba34c..de7924463fd389e5edb8304834f310581c8ab580 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -5255,6 +5255,29 @@ Input type: `DestroyComplianceRequirementInput` | <a id="mutationdestroycompliancerequirementclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationdestroycompliancerequirementerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.destroyComplianceRequirementsControl` + +{{< details >}} +**Introduced** in GitLab 17.9. +**Status**: Experiment. +{{< /details >}} + +Input type: `DestroyComplianceRequirementsControlInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationdestroycompliancerequirementscontrolclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationdestroycompliancerequirementscontrolid"></a>`id` | [`ComplianceManagementComplianceFrameworkComplianceRequirementsControlID!`](#compliancemanagementcomplianceframeworkcompliancerequirementscontrolid) | Global ID of the compliance requirement control to destroy. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationdestroycompliancerequirementscontrolclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationdestroycompliancerequirementscontrolerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.destroyContainerRepository` Input type: `DestroyContainerRepositoryInput` diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index a0b9911c98549750460ca6c033ad7ef117e45af6..05ad559276c7606011caca847bb1b50e180c4cd6 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -159,6 +159,7 @@ Audit event types belong to the following product categories. | [`delete_status_check`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84624) | An external status check is deleted | {{< icon name="check-circle" >}} Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/355805) | Project | | [`destroy_compliance_framework`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74292) | A compliance framework is successfully deleted | {{< icon name="check-circle" >}} Yes | GitLab [14.6](https://gitlab.com/gitlab-org/gitlab/-/issues/340649) | Group | | [`destroyed_compliance_requirement`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/170380) | A compliance framework requirement is destroyed | {{< icon name="check-circle" >}} Yes | GitLab [17.7](https://gitlab.com/gitlab-org/gitlab/-/issues/470695) | Group | +| [`destroyed_compliance_requirement_control`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/177878) | A compliance requirement control is destroyed. | {{< icon name="check-circle" >}} Yes | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/512381) | Group | | [`email_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/114546) | An email is created | {{< icon name="check-circle" >}} Yes | GitLab [15.11](https://gitlab.com/gitlab-org/gitlab/-/issues/374107) | User | | [`email_destroyed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/114546) | An email is destroyed | {{< icon name="check-circle" >}} Yes | GitLab [15.11](https://gitlab.com/gitlab-org/gitlab/-/issues/374107) | User | | [`external_status_check_name_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106095) | The name of an external status check is updated | {{< icon name="check-circle" >}} Yes | GitLab [15.7](https://gitlab.com/gitlab-org/gitlab/-/issues/369333) | Project | diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index 3b18eeae4faf50c5721b75a9e5b081a9c97fa258..1cdcb6b9ef56490e76aa18c1e3718c7c0be84508 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -276,6 +276,8 @@ def self.authorization_scopes experiment: { milestone: '17.9' } mount_mutation ::Mutations::ComplianceManagement::ComplianceFramework::ComplianceRequirementsControls::Update, experiment: { milestone: '17.9' } + mount_mutation ::Mutations::ComplianceManagement::ComplianceFramework::ComplianceRequirementsControls::Destroy, + experiment: { milestone: '17.9' } mount_mutation ::Mutations::Ai::DuoSettings::Update, experiment: { milestone: '17.9' } mount_mutation ::Mutations::Ai::DeleteConversationThread, experiment: { milestone: '17.9' } diff --git a/ee/app/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/destroy.rb b/ee/app/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/destroy.rb new file mode 100644 index 0000000000000000000000000000000000000000..09d87e10a4fe3c6a0b40dbc99ce747d088f5fbca --- /dev/null +++ b/ee/app/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/destroy.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module ComplianceManagement + module ComplianceFramework + module ComplianceRequirementsControls + class Destroy < BaseMutation + graphql_name 'DestroyComplianceRequirementsControl' + + authorize :admin_compliance_framework + + argument :id, ::Types::GlobalIDType[ + ::ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl + ], + required: true, + description: 'Global ID of the compliance requirement control to destroy.' + + def resolve(id:) + control = authorized_find!(id: id) + + result = ::ComplianceManagement::ComplianceFramework::ComplianceRequirementsControls::DestroyService.new( + control: control, current_user: current_user).execute + + { errors: result.success? ? [] : Array.wrap(result.message) } + end + end + end + end + end +end diff --git a/ee/app/services/compliance_management/compliance_framework/compliance_requirements_controls/destroy_service.rb b/ee/app/services/compliance_management/compliance_framework/compliance_requirements_controls/destroy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..3c5e00fdf6c2a40f643adf138663a2d2d8491cc3 --- /dev/null +++ b/ee/app/services/compliance_management/compliance_framework/compliance_requirements_controls/destroy_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module ComplianceManagement + module ComplianceFramework + module ComplianceRequirementsControls + class DestroyService < BaseService + attr_reader :current_user, :control + + def initialize(control:, current_user:) + @control = control + @current_user = current_user + end + + def execute + return ServiceResponse.error(message: _('Not permitted to destroy requirement control')) unless permitted? + + control.destroy ? success : error + end + + private + + def permitted? + can? current_user, :admin_compliance_framework, control.compliance_requirement.framework + end + + def success + audit_destroy + + ServiceResponse.success(message: _('Compliance requirement control successfully deleted')) + end + + def audit_destroy + audit_context = { + name: 'destroyed_compliance_requirement_control', + author: current_user, + scope: control.namespace, + target: control, + message: "Destroyed compliance requirement control #{control.name}" + } + + ::Gitlab::Audit::Auditor.audit(audit_context) + end + + def error + ServiceResponse.error(message: _('Failed to destroy compliance requirement control'), payload: control.errors) + end + end + end + end +end diff --git a/ee/config/audit_events/types/destroyed_compliance_requirement_control.yml b/ee/config/audit_events/types/destroyed_compliance_requirement_control.yml new file mode 100644 index 0000000000000000000000000000000000000000..da2a413c87d34a46edafd633f2f8d70edd793641 --- /dev/null +++ b/ee/config/audit_events/types/destroyed_compliance_requirement_control.yml @@ -0,0 +1,10 @@ +--- +name: destroyed_compliance_requirement_control +description: A compliance requirement control is destroyed. +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/512381 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/177878 +milestone: '17.9' +feature_category: compliance_management +saved_to_database: true +streamed: true +scope: [Group] diff --git a/ee/spec/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/destroy_spec.rb b/ee/spec/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/destroy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..952a3e03319a70a1c587f470d75e5e2dac2b1c55 --- /dev/null +++ b/ee/spec/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/destroy_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::ComplianceManagement::ComplianceFramework::ComplianceRequirementsControls::Destroy, + feature_category: :compliance_management do + include GraphqlHelpers + + let_it_be(:namespace) { create(:group) } + let_it_be(:requirement) do + create(:compliance_requirement, framework: create(:compliance_framework, namespace: namespace)) + end + + let_it_be(:control) { create(:compliance_requirements_control, compliance_requirement: requirement) } + + let_it_be(:current_user) { create(:user) } + let(:mutation) { described_class.new(object: nil, context: query_context, field: nil) } + + subject(:mutate) { mutation.resolve(id: global_id_of(control)) } + + before_all do + namespace.add_owner(current_user) + end + + context 'when feature is unlicensed' do + before do + stub_licensed_features(custom_compliance_frameworks: false) + end + + it 'raises an error' do + expect { mutate }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when feature is licensed' do + before do + stub_licensed_features(custom_compliance_frameworks: true) + end + + it 'destroys a compliance requirement control' do + expect { mutate }.to change { + ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.exists?(id: control.id) + }.from(true).to(false) + end + + it 'expects zero errors in the response' do + expect(mutate[:errors]).to be_empty + end + end +end diff --git a/ee/spec/requests/api/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/destroy_spec.rb b/ee/spec/requests/api/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/destroy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3989e90d370eb6836368778197024d44c6a3f302 --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/destroy_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Destroy a Compliance Requirement Control', feature_category: :compliance_management do + include GraphqlHelpers + + let_it_be(:namespace) { create(:group) } + let_it_be(:requirement) do + create(:compliance_requirement, framework: create(:compliance_framework, namespace: namespace)) + end + + let_it_be(:control) { create(:compliance_requirements_control, compliance_requirement: requirement) } + + let_it_be(:current_user) { create(:user) } + let(:mutation) { graphql_mutation(:destroy_compliance_requirements_control, { id: global_id_of(control) }) } + + subject(:mutate) { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:destroy_compliance_requirements_control) + end + + context 'when feature is unlicensed' do + before do + stub_licensed_features(custom_compliance_frameworks: false) + end + + it 'does not destroy a compliance requirement control' do + expect { mutate }.not_to change { ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.count } + end + + it_behaves_like 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] + end + + context 'when licensed' do + before do + stub_licensed_features(custom_compliance_frameworks: true) + end + + context 'when current_user is namespace owner' do + before_all do + namespace.add_owner(current_user) + end + + it 'has no errors' do + mutate + + expect(mutation_response['errors']).to be_empty + end + + it 'destroys a compliance requirement control' do + expect { mutate }.to change { + ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.exists?(id: control.id) + }.from(true).to(false) + end + end + + context 'when current_user is not namespace owner' do + it_behaves_like 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] + + it 'does not destroy a compliance requirement control' do + expect { mutate } + .not_to change { ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.count } + end + end + end +end diff --git a/ee/spec/services/compliance_management/compliance_framework/compliance_requirements_controls/destroy_service_spec.rb b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements_controls/destroy_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..18ccd241f189eea20962ddad3902b07c24edd49c --- /dev/null +++ b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements_controls/destroy_service_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ComplianceManagement::ComplianceFramework::ComplianceRequirementsControls::DestroyService, + feature_category: :compliance_management do + let_it_be_with_refind(:namespace) { create(:group) } + let_it_be(:requirement) do + create(:compliance_requirement, framework: create(:compliance_framework, namespace: namespace)) + end + + let_it_be(:control) { create(:compliance_requirements_control, compliance_requirement: requirement) } + let_it_be(:owner) { create(:user, owner_of: namespace) } + let_it_be(:non_owner) { create(:user) } + + shared_examples 'unsuccessful destruction' do |error_message| + it 'does not destroy the compliance requirement control' do + expect { service.execute } + .not_to change { ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.count } + end + + it 'is unsuccessful' do + result = service.execute + + expect(result.success?).to be false + expect(result.message).to eq _(error_message) + end + + it 'does not audit the destruction' do + service.execute + + expect(::Gitlab::Audit::Auditor).not_to have_received(:audit) + end + end + + context 'when feature is disabled' do + before do + stub_licensed_features(custom_compliance_frameworks: false) + allow(::Gitlab::Audit::Auditor).to receive(:audit) + end + + context 'when current user is namespace owner' do + subject(:service) { described_class.new(control: control, current_user: owner) } + + it_behaves_like 'unsuccessful destruction', 'Not permitted to destroy requirement control' + end + + context 'when current user is not the namespace owner' do + subject(:service) { described_class.new(control: control, current_user: non_owner) } + + it_behaves_like 'unsuccessful destruction', 'Not permitted to destroy requirement control' + end + end + + context 'when feature is enabled' do + before do + stub_licensed_features(custom_compliance_frameworks: true) + allow(::Gitlab::Audit::Auditor).to receive(:audit) + end + + context 'when current user is namespace owner' do + subject(:service) { described_class.new(control: control, current_user: owner) } + + it 'destroys the compliance requirement control' do + expect { service.execute }.to change { + ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.exists?(id: control.id) + }.from(true).to(false) + end + + it 'is successful' do + result = service.execute + + expect(result.success?).to be true + expect(result.message).to eq _('Compliance requirement control successfully deleted') + end + + it 'audits the destruction' do + service.execute + + expect(::Gitlab::Audit::Auditor).to have_received(:audit).with( + name: 'destroyed_compliance_requirement_control', + author: owner, + scope: control.namespace, + target: control, + message: "Destroyed compliance requirement control #{control.name}" + ) + end + + context 'when destruction fails' do + before do + allow(control).to receive(:destroy).and_return(false) + end + + it 'is unsuccessful' do + result = service.execute + + expect(result.success?).to be false + expect(result.message).to eq _('Failed to destroy compliance requirement control') + end + end + end + + context 'when current user is not the namespace owner' do + subject(:service) { described_class.new(control: control, current_user: non_owner) } + + it 'does not destroy the compliance requirement control' do + expect { service.execute } + .not_to change { ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.count } + end + + it_behaves_like 'unsuccessful destruction', 'Not permitted to destroy requirement control' + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index e34d594479ae1f19519a388e7d63bc7c3ebc135b..959bc33dfd99093b0fc33f82e10921d8d72caf91 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14613,6 +14613,9 @@ msgstr "" msgid "Compliance frameworks" msgstr "" +msgid "Compliance requirement control successfully deleted" +msgstr "" + msgid "Compliance requirement successfully deleted" msgstr "" @@ -24277,6 +24280,9 @@ msgstr "" msgid "Failed to destroy compliance requirement" msgstr "" +msgid "Failed to destroy compliance requirement control" +msgstr "" + msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later." msgstr "" @@ -38485,6 +38491,9 @@ msgstr "" msgid "Not permitted to destroy requirement" msgstr "" +msgid "Not permitted to destroy requirement control" +msgstr "" + msgid "Not permitted to reset user feed token" msgstr ""