diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 69fbd45ac535cd977a2e1911857c9a633728acc6..80120964dffad12bae4298a1f2fb8ea3856504e2 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -4689,6 +4689,28 @@ Input type: `DestroyComplianceFrameworkInput` | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.destroyComplianceRequirement` + +DETAILS: +**Introduced** in GitLab 17.7. +**Status**: Experiment. + +Input type: `DestroyComplianceRequirementInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `id` | [`ComplianceManagementComplianceFrameworkComplianceRequirementID!`](#compliancemanagementcomplianceframeworkcompliancerequirementid) | Global ID of the compliance requirement to destroy. | + +#### 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. | + ### `Mutation.destroyContainerRepository` Input type: `DestroyContainerRepositoryInput` @@ -41232,6 +41254,12 @@ Color represented as a hex code or named color. For example: "#fefefe". +### `ComplianceManagementComplianceFrameworkComplianceRequirementID` + +A `ComplianceManagementComplianceFrameworkComplianceRequirementID` is a global ID. It is encoded as a string. + +An example `ComplianceManagementComplianceFrameworkComplianceRequirementID` is: `"gid://gitlab/ComplianceManagement::ComplianceFramework::ComplianceRequirement/1"`. + ### `ComplianceManagementFrameworkID` A `ComplianceManagementFrameworkID` is a global ID. It is encoded as a string. diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index 41be7a311633606676c54c5c1ce0154678e0c70f..6bb9ad7f500e99c0d1b1e5587914d267a378858c 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -149,6 +149,7 @@ Audit event types belong to the following product categories. | [`created_compliance_requirement`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/169485) | Triggered when a requirement is added to a compliance framework | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.6](https://gitlab.com/gitlab-org/gitlab/-/issues/470695) | Group | | [`delete_status_check`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84624) | Triggered when an external status check is deleted | **{check-circle}** Yes | **{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) | Triggered when a compliance framework is successfully deleted | **{check-circle}** Yes | **{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) | Triggered when a compliance framework requirement is destroyed | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.7](https://gitlab.com/gitlab-org/gitlab/-/issues/470695) | Group | | [`email_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/114546) | Triggered when an email is created | **{check-circle}** Yes | **{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) | Triggered when an email is destroyed | **{check-circle}** Yes | **{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) | Triggered when the name of an external status check is updated | **{check-circle}** Yes | **{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 efe930e5b7145fc49ed10efe9b611381ca5c29b1..f187eba8a6274523c57a177619a1f87d0e605a6b 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -255,6 +255,8 @@ def self.authorization_scopes mount_mutation ::Mutations::Projects::TargetBranchRules::Destroy mount_mutation ::Mutations::ComplianceManagement::ComplianceFramework::ComplianceRequirements::Create, experiment: { milestone: '17.6' } + mount_mutation ::Mutations::ComplianceManagement::ComplianceFramework::ComplianceRequirements::Destroy, + experiment: { milestone: '17.7' } prepend(Types::DeprecatedMutations) end diff --git a/ee/app/graphql/mutations/compliance_management/compliance_framework/compliance_requirements/destroy.rb b/ee/app/graphql/mutations/compliance_management/compliance_framework/compliance_requirements/destroy.rb new file mode 100644 index 0000000000000000000000000000000000000000..e6f5aed25a1989736b6a83216688b83226d3937f --- /dev/null +++ b/ee/app/graphql/mutations/compliance_management/compliance_framework/compliance_requirements/destroy.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module ComplianceManagement + module ComplianceFramework + module ComplianceRequirements + class Destroy < BaseMutation + graphql_name 'DestroyComplianceRequirement' + authorize :admin_compliance_framework + + argument :id, ::Types::GlobalIDType[::ComplianceManagement::ComplianceFramework::ComplianceRequirement], + required: true, + description: 'Global ID of the compliance requirement to destroy.' + + def resolve(id:) + requirement = authorized_find!(id: id) + + result = ::ComplianceManagement::ComplianceFramework::ComplianceRequirements::DestroyService.new( + requirement: requirement, current_user: current_user).execute + + { errors: result.success? ? [] : Array.wrap(result.message) } + end + end + end + end + end +end diff --git a/ee/app/policies/compliance_management/compliance_framework/compliance_requirement_policy.rb b/ee/app/policies/compliance_management/compliance_framework/compliance_requirement_policy.rb new file mode 100644 index 0000000000000000000000000000000000000000..278f97a87c616c8eacad4e48aec467c6366554c8 --- /dev/null +++ b/ee/app/policies/compliance_management/compliance_framework/compliance_requirement_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ComplianceManagement + module ComplianceFramework + class ComplianceRequirementPolicy < BasePolicy + delegate { @subject.framework } + end + end +end diff --git a/ee/app/services/compliance_management/compliance_framework/compliance_requirements/destroy_service.rb b/ee/app/services/compliance_management/compliance_framework/compliance_requirements/destroy_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..f6c77494eca26745a5b9b71525f35e19b0695776 --- /dev/null +++ b/ee/app/services/compliance_management/compliance_framework/compliance_requirements/destroy_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module ComplianceManagement + module ComplianceFramework + module ComplianceRequirements + class DestroyService < BaseService + attr_reader :current_user, :requirement + + def initialize(requirement:, current_user:) + @requirement = requirement + @current_user = current_user + end + + def execute + return ServiceResponse.error(message: _('Not permitted to destroy requirement')) unless permitted? + + requirement.destroy ? success : error + end + + private + + def permitted? + can? current_user, :admin_compliance_framework, requirement.framework + end + + def success + audit_destroy + + ServiceResponse.success(message: _('Compliance requirement successfully deleted')) + end + + def audit_destroy + audit_context = { + name: 'destroyed_compliance_requirement', + author: current_user, + scope: requirement.framework.namespace, + target: requirement, + message: "Destroyed compliance requirement #{requirement.name}" + } + + ::Gitlab::Audit::Auditor.audit(audit_context) + end + + def error + ServiceResponse.error(message: _('Failed to destroy compliance requirement'), payload: requirement.errors) + end + end + end + end +end diff --git a/ee/config/audit_events/types/destroyed_compliance_requirement.yml b/ee/config/audit_events/types/destroyed_compliance_requirement.yml new file mode 100644 index 0000000000000000000000000000000000000000..82ee7bb03d85c5825318953200bd591f1caa0208 --- /dev/null +++ b/ee/config/audit_events/types/destroyed_compliance_requirement.yml @@ -0,0 +1,10 @@ +--- +name: destroyed_compliance_requirement +description: Triggered when a compliance framework requirement is destroyed +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/470695 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/170380 +milestone: '17.7' +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/destroy_spec.rb b/ee/spec/graphql/mutations/compliance_management/compliance_framework/compliance_requirements/destroy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..34c70781db8ce9859a16e072491780a5b4cf402a --- /dev/null +++ b/ee/spec/graphql/mutations/compliance_management/compliance_framework/compliance_requirements/destroy_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::ComplianceManagement::ComplianceFramework::ComplianceRequirements::Destroy, + feature_category: :compliance_management do + include GraphqlHelpers + + let_it_be(:namespace) { create(:group) } + let_it_be(:framework) { create(:compliance_framework, namespace: namespace) } + let_it_be(:requirement) { create(:compliance_requirement, framework: framework) } + + let_it_be(:current_user) { create(:user) } + let(:mutation) { described_class.new(object: nil, context: query_context, field: nil) } + + subject { mutation.resolve(id: global_id_of(requirement)) } + + before_all do + namespace.add_owner(current_user) + end + + shared_examples 'a compliance requirement that cannot be found' do + it 'raises an error' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + shared_examples 'one compliance requirement was destroyed' do + it 'destroys a compliance requirement' do + expect { subject }.to change { + ComplianceManagement::ComplianceFramework::ComplianceRequirement.exists?(id: requirement.id) + }.from(true).to(false) + end + + it 'expects zero errors in the response' do + expect(subject[:errors]).to be_empty + end + end + + context 'when feature is unlicensed' do + before do + stub_licensed_features(custom_compliance_frameworks: false) + end + + it_behaves_like 'a compliance requirement that cannot be found' + end + + context 'when feature is licensed' do + before do + stub_licensed_features(custom_compliance_frameworks: true) + end + + context 'when current_user is namespace owner' do + it_behaves_like 'one compliance requirement was destroyed' + end + + context 'when current_user is group owner' do + let_it_be(:group) { create(:group) } + let_it_be(:current_user) { create(:user) } + let_it_be(:framework) { create(:compliance_framework, namespace: group) } + let_it_be(:requirement) { create(:compliance_requirement, framework: framework) } + + before_all do + group.add_owner(current_user) + end + + it_behaves_like 'one compliance requirement was destroyed' + end + end +end diff --git a/ee/spec/requests/api/graphql/mutations/compliance_management/compliance_framework/compliance_requirements/destroy_spec.rb b/ee/spec/requests/api/graphql/mutations/compliance_management/compliance_framework/compliance_requirements/destroy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..985d5e93ed5577dff16b2c7092a286bc551e7b5f --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/compliance_management/compliance_framework/compliance_requirements/destroy_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Destroy a Compliance Requirement', feature_category: :compliance_management do + include GraphqlHelpers + + let_it_be(:namespace) { create(:group) } + let_it_be(:framework) { create(:compliance_framework, namespace: namespace) } + let_it_be(:requirement) { create(:compliance_requirement, framework: framework) } + + let_it_be(:current_user) { create(:user) } + let(:mutation) { graphql_mutation(:destroy_compliance_requirement, { id: global_id_of(requirement) }) } + + subject(:mutate) { post_graphql_mutation(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:destroy_compliance_requirement) + end + + context 'when feature is unlicensed' do + before do + stub_licensed_features(custom_compliance_frameworks: false) + end + + it 'does not destroy a compliance requirement' do + expect { mutate }.not_to change { ComplianceManagement::ComplianceFramework::ComplianceRequirement.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' do + expect { mutate }.to change { + ComplianceManagement::ComplianceFramework::ComplianceRequirement.exists?(id: requirement.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' do + expect { mutate }.not_to change { ComplianceManagement::ComplianceFramework::ComplianceRequirement.count } + end + end + end +end diff --git a/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/destroy_service_spec.rb b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/destroy_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a0d80907315f3427ac4f0adf95f9e464c01d155f --- /dev/null +++ b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements/destroy_service_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ComplianceManagement::ComplianceFramework::ComplianceRequirements::DestroyService, + feature_category: :compliance_management do + let_it_be_with_refind(:namespace) { create(:group) } + let_it_be_with_refind(:framework) { create(:compliance_framework, namespace: namespace) } + let_it_be_with_refind(:requirement) { create(:compliance_requirement, framework: framework) } + 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' do + expect { service.execute } + .not_to change { ComplianceManagement::ComplianceFramework::ComplianceRequirement.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(requirement: requirement, current_user: owner) } + + it_behaves_like 'unsuccessful destruction', 'Not permitted to destroy requirement' + end + + context 'when current user is not the namespace owner' do + subject(:service) { described_class.new(requirement: requirement, current_user: non_owner) } + + it_behaves_like 'unsuccessful destruction', 'Not permitted to destroy requirement' + 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(requirement: requirement, current_user: owner) } + + it 'destroys the compliance requirement' do + expect { service.execute }.to change { + ComplianceManagement::ComplianceFramework::ComplianceRequirement.exists?(id: requirement.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 successfully deleted') + end + + it 'audits the destruction' do + service.execute + + expect(::Gitlab::Audit::Auditor).to have_received(:audit).with( + name: 'destroyed_compliance_requirement', + author: owner, + scope: requirement.framework.namespace, + target: requirement, + message: "Destroyed compliance requirement #{requirement.name}" + ) + end + + context 'when destruction fails' do + before do + allow_next_found_instance_of(ComplianceManagement::ComplianceFramework::ComplianceRequirement) do |instance| + allow(instance).to receive(:destroy).and_return(false) + end + end + + it 'is unsuccessful' do + result = service.execute + + expect(result.success?).to be false + expect(result.message).to eq _('Failed to destroy compliance requirement') + end + end + end + + context 'when current user is not the namespace owner' do + subject(:service) { described_class.new(requirement: requirement, current_user: non_owner) } + + it 'does not destroy the compliance requirement' do + expect { service.execute } + .not_to change { ComplianceManagement::ComplianceFramework::ComplianceRequirement.count } + end + + it_behaves_like 'unsuccessful destruction', 'Not permitted to destroy requirement' + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c796bf5f4bdfcae223ee9b7c4165dfa5275bff79..ecd997be206e0d3097ab4d1af017b81fcb1daedf 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14157,6 +14157,9 @@ msgstr "" msgid "Compliance frameworks" msgstr "" +msgid "Compliance requirement successfully deleted" +msgstr "" + msgid "ComplianceChainOfCustody| Chain of custody export" msgstr "" @@ -23131,6 +23134,9 @@ msgstr "" msgid "Failed to deploy to" msgstr "" +msgid "Failed to destroy compliance requirement" +msgstr "" + msgid "Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later." msgstr "" @@ -36797,6 +36803,9 @@ msgstr "" msgid "Not permitted to destroy framework" msgstr "" +msgid "Not permitted to destroy requirement" +msgstr "" + msgid "Not permitted to reset user feed token" msgstr ""