diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index 6f8ad6ccf6bb9ad6fcc8509483b3859a8f8fc8a5..dc95b896a9ea282d0ec5fb4eebde0e53fe7764d1 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -12191,6 +12191,31 @@ Input type: `UpdatePackagesProtectionRuleInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
| `packageProtectionRule` | [`PackagesProtectionRule`](#packagesprotectionrule) | Packages protection rule after mutation. |
+### `Mutation.updateProjectComplianceViolation`
+
+{{< details >}}
+**Introduced** in GitLab 18.2.
+**Status**: Experiment.
+{{< /details >}}
+
+Input type: `UpdateProjectComplianceViolationInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `id` | [`ComplianceManagementProjectsComplianceViolationID!`](#compliancemanagementprojectscomplianceviolationid) | Global ID of the project compliance violation to update. |
+| `status` | [`ComplianceViolationStatus!`](#complianceviolationstatus) | New status for the project compliance violation. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `complianceViolation` | [`ProjectComplianceViolation`](#projectcomplianceviolation) | Compliance violation after status update. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. |
+
### `Mutation.updateRequirement`
Input type: `UpdateRequirementInput`
diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md
index 85a789aa9bf0807e15e602885e9787b56e589d54..3c9fbdd6aef471c36168e2ecd8c2363e14230adc 100644
--- a/doc/user/compliance/audit_event_types.md
+++ b/doc/user/compliance/audit_event_types.md
@@ -216,6 +216,7 @@ Audit event types belong to the following product categories.
| [`update_approval_rules`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89939) | Updating a merge approval rule | {{< icon name="check-circle" >}} Yes | GitLab [15.2](https://gitlab.com/gitlab-org/gitlab/-/issues/363092) | Project |
| [`update_compliance_framework`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74292) | A compliance framework is updated | {{< icon name="check-circle" >}} Yes | GitLab [14.6](https://gitlab.com/gitlab-org/gitlab/-/issues/340649) | Group |
| [`update_compliance_requirement`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/169485) | A compliance framework requirement is updated. | {{< icon name="check-circle" >}} Yes | GitLab [17.7](https://gitlab.com/gitlab-org/gitlab/-/issues/470695) | Group |
+| [`update_project_compliance_violation`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195768) | Project compliance violation is updated | {{< icon name="check-circle" >}} Yes | GitLab [18.2](https://gitlab.com/gitlab-org/gitlab/-/issues/542343) | Project |
| [`update_status_check`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84624) | An external status check is updated | {{< icon name="check-circle" >}} Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/355805) | Project |
| [`updated_compliance_requirement_control`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/177557) | Compliance requirement control updated. | {{< icon name="check-circle" >}} Yes | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/512381) | Group |
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index d6adc01483127ceaed685683455ef6cab494ec51..a30d0340e126106e6b80a0c30e7e43a6c418dab2 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -320,6 +320,8 @@ def self.authorization_scopes
mount_mutation ::Mutations::WorkItems::Lifecycles::Update, experiment: { milestone: '18.1' }
mount_mutation ::Mutations::VirtualRegistries::Packages::Maven::MavenUpstreamCreateMutation,
experiment: { milestone: '18.2' }
+ mount_mutation ::Mutations::ComplianceManagement::Projects::ComplianceViolations::Update,
+ experiment: { milestone: '18.2' }
prepend(Types::DeprecatedMutations)
end
diff --git a/ee/app/graphql/mutations/compliance_management/projects/compliance_violations/update.rb b/ee/app/graphql/mutations/compliance_management/projects/compliance_violations/update.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5462493805e3fda9a47fd6d2a3570cef5af5f892
--- /dev/null
+++ b/ee/app/graphql/mutations/compliance_management/projects/compliance_violations/update.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ComplianceManagement
+ module Projects
+ module ComplianceViolations
+ class Update < ::Mutations::BaseMutation
+ graphql_name 'UpdateProjectComplianceViolation'
+
+ authorize :read_compliance_violations_report
+
+ argument :id,
+ ::Types::GlobalIDType[::ComplianceManagement::Projects::ComplianceViolation],
+ required: true,
+ description: 'Global ID of the project compliance violation to update.'
+
+ argument :status, ::Types::ComplianceManagement::Projects::ComplianceViolationStatusEnum,
+ required: true,
+ description: 'New status for the project compliance violation.'
+
+ field :compliance_violation,
+ ::Types::ComplianceManagement::Projects::ComplianceViolationType,
+ null: true,
+ description: "Compliance violation after status update."
+
+ def resolve(id:, **args)
+ violation = authorized_find!(id: id)
+
+ audit_event(violation) if violation.update(status: args[:status])
+
+ { compliance_violation: violation, errors: errors_on_object(violation) }
+ end
+
+ private
+
+ def audit_event(violation)
+ old_status = violation.status_before_last_save
+ new_status = violation.status
+
+ return if old_status == new_status
+
+ audit_context = {
+ name: 'update_project_compliance_violation',
+ author: current_user,
+ scope: violation.project,
+ target: violation,
+ message: "Changed project compliance violation's status from #{old_status} to #{new_status}",
+ additional_details: {
+ old_status: old_status,
+ new_status: new_status
+ }
+ }
+
+ ::Gitlab::Audit::Auditor.audit(audit_context)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/config/audit_events/types/update_project_compliance_violation.yml b/ee/config/audit_events/types/update_project_compliance_violation.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1130b484b95da9944266e054aea26f8bb480a57b
--- /dev/null
+++ b/ee/config/audit_events/types/update_project_compliance_violation.yml
@@ -0,0 +1,10 @@
+---
+name: update_project_compliance_violation
+description: Project compliance violation is updated
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/542343
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/195768
+feature_category: compliance_management
+milestone: '18.2'
+saved_to_database: true
+streamed: true
+scope: [Project]
diff --git a/ee/spec/graphql/mutations/compliance_management/projects/compliance_violations/update_spec.rb b/ee/spec/graphql/mutations/compliance_management/projects/compliance_violations/update_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ce87dffc31da45a1bd320d9c10a7051f9ebe1f56
--- /dev/null
+++ b/ee/spec/graphql/mutations/compliance_management/projects/compliance_violations/update_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::ComplianceManagement::Projects::ComplianceViolations::Update,
+ feature_category: :compliance_management do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:compliance_violation) { create(:project_compliance_violation, project: project, namespace: group) }
+
+ let(:mutation) { described_class.new(object: nil, context: query_context, field: nil) }
+
+ before_all do
+ group.add_owner(current_user)
+ end
+
+ describe '#resolve' do
+ context 'when feature is licensed' do
+ before do
+ stub_licensed_features(group_level_compliance_violations_report: true)
+ end
+
+ context 'when user is authorized' do
+ context 'when updating status successfully' do
+ it 'updates the violation status' do
+ result = mutation.resolve(id: compliance_violation.to_global_id, status: 'in_review')
+
+ expect(result[:compliance_violation].status).to eq('in_review')
+ expect(result[:errors]).to be_empty
+ end
+
+ it 'persists the status change' do
+ mutation.resolve(id: compliance_violation.to_global_id, status: 'resolved')
+
+ expect(compliance_violation.reload.status).to eq('resolved')
+ end
+ end
+
+ context 'when validation fails' do
+ before do
+ allow_next_instance_of(ComplianceManagement::Projects::ComplianceViolation) do |violation|
+ allow(violation).to receive_messages(update: false,
+ errors: instance_double(ActiveModel::Errors, full_messages: ['Status is invalid']))
+ end
+ end
+
+ it 'returns validation errors' do
+ expect do
+ mutation.resolve(id: compliance_violation.to_global_id, status: 'invalid_status')
+ end.to raise_error(ArgumentError)
+ end
+ end
+ end
+
+ context 'when user is not authorized' do
+ before_all do
+ group.add_maintainer(current_user)
+ end
+
+ it 'raises authorization error' do
+ expect { mutation.resolve(id: compliance_violation.to_global_id, status: 'in_review') }
+ .to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+
+ context 'when feature is not licensed' do
+ before do
+ stub_licensed_features(group_level_compliance_violations_report: false)
+ end
+
+ it 'raises authorization error' do
+ expect { mutation.resolve(id: compliance_violation.to_global_id, status: 'in_review') }
+ .to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+end
diff --git a/ee/spec/requests/api/graphql/mutations/compliance_management/projects/compliance_violations/update_spec.rb b/ee/spec/requests/api/graphql/mutations/compliance_management/projects/compliance_violations/update_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..16addb2941811407bccfe3ae987e1c4cdd680451
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/compliance_management/projects/compliance_violations/update_spec.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'UpdateProjectComplianceViolation', feature_category: :compliance_management do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group) }
+ let(:error_message) do
+ "The resource that you are attempting to access does not exist or you don't have permission to perform this action"
+ end
+
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:user) { create(:user) }
+ let(:compliance_violation) { create(:project_compliance_violation, project: project, namespace: group) }
+
+ let(:mutation) do
+ graphql_mutation(
+ :update_project_compliance_violation,
+ {
+ id: compliance_violation.to_global_id.to_s,
+ status: new_status
+ }
+ )
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:update_project_compliance_violation) }
+
+ subject(:mutate) { post_graphql_mutation(mutation, current_user: user) }
+
+ before do
+ stub_licensed_features(group_level_compliance_violations_report: true)
+ end
+
+ before_all do
+ group.add_owner(user)
+ end
+
+ context 'when user has permission to read compliance violations report' do
+ shared_examples 'does not create audit event' do
+ it 'does not create any audit event' do
+ expect { mutate }.not_to change {
+ AuditEvent.where("details LIKE '%update_project_compliance_violation%'").count
+ }
+ end
+ end
+
+ context 'with valid parameters' do
+ let(:new_status) { 'IN_REVIEW' }
+
+ it 'updates the compliance violation status' do
+ mutate
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['complianceViolation']['id']).to eq(compliance_violation.to_global_id.to_s)
+ expect(mutation_response['errors']).to be_empty
+
+ expect(compliance_violation.reload.status).to eq('in_review')
+ end
+
+ it 'returns the updated compliance violation' do
+ mutate
+
+ expect(mutation_response['complianceViolation']).to include(
+ 'id' => compliance_violation.to_global_id.to_s,
+ 'status' => new_status
+ )
+ end
+
+ it 'audits the change' do
+ expect { mutate }.to change {
+ AuditEvent.where("details LIKE '%update_project_compliance_violation%'").count
+ }.by(1)
+ end
+
+ context 'when status is same as previous one' do
+ let(:new_status) { compliance_violation.status.upcase }
+
+ it 'does return any error' do
+ mutate
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['errors']).to be_empty
+ end
+
+ it_behaves_like 'does not create audit event'
+ end
+ end
+
+ context 'with different status values' do
+ %w[DETECTED IN_REVIEW RESOLVED DISMISSED].each do |status|
+ context "when updating to #{status}" do
+ let(:new_status) { status }
+
+ it "successfully updates status to #{status}" do
+ mutate
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['errors']).to be_empty
+ expect(compliance_violation.reload.status).to eq(status.downcase)
+ end
+ end
+ end
+ end
+
+ context 'with invalid parameters' do
+ context 'when compliance violation does not exist' do
+ let(:mutation) do
+ graphql_mutation(
+ :update_project_compliance_violation,
+ {
+ id: "gid://gitlab/ComplianceManagement::Projects::ComplianceViolation/#{non_existing_record_id}",
+ status: 'IN_REVIEW'
+ }
+ )
+ end
+
+ it 'returns an error' do
+ mutate
+
+ expect(graphql_errors).to include(a_hash_including('message' => error_message))
+ end
+
+ it_behaves_like 'does not create audit event'
+ end
+
+ context 'when status is invalid' do
+ let(:new_status) { 'INVALID_STATUS' }
+
+ it 'returns a validation error' do
+ mutate
+
+ expect(graphql_errors).to include(
+ a_hash_including('message' => a_string_matching(/was provided invalid value for status/i))
+ )
+ end
+
+ it_behaves_like 'does not create audit event'
+ end
+
+ context 'when compliance violation belongs to different project' do
+ let_it_be(:other_group) { create(:group) }
+ let_it_be(:other_project) { create(:project, group: other_group) }
+ let_it_be(:other_violation) do
+ create(:project_compliance_violation, project: other_project, namespace: other_group)
+ end
+
+ let(:mutation) do
+ graphql_mutation(
+ :update_project_compliance_violation,
+ {
+ id: other_violation.to_global_id.to_s,
+ status: 'IN_REVIEW'
+ }
+ )
+ end
+
+ it 'returns an authorization error' do
+ mutate
+
+ expect(graphql_errors).to include(a_hash_including('message' => error_message))
+ end
+
+ it_behaves_like 'does not create audit event'
+ end
+ end
+ end
+
+ context 'when user does not have permission' do
+ let(:new_status) { 'IN_REVIEW' }
+
+ before_all do
+ group.add_maintainer(user)
+ end
+
+ it 'returns an authorization error' do
+ mutate
+
+ expect(graphql_errors).to include(a_hash_including('message' => error_message))
+ end
+
+ it_behaves_like 'does not create audit event'
+ end
+
+ context 'when feature is not licensed' do
+ let(:new_status) { 'IN_REVIEW' }
+
+ before do
+ stub_licensed_features(group_level_compliance_violations_report: false)
+ end
+
+ it 'returns an error' do
+ mutate
+
+ expect(graphql_errors).to include(a_hash_including('message' => error_message))
+ end
+
+ it_behaves_like 'does not create audit event'
+ end
+end