diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index b1fb1f542e7c07b5a1b0a924878176e2cbc6ec0e..dafdb521da120eda5f9a7f109cab5d5e936e403d 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -3736,6 +3736,30 @@ Input type: `CreateComplianceRequirementInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `requirement` | [`ComplianceRequirement`](#compliancerequirement) | Created compliance requirement. |
+### `Mutation.createComplianceRequirementsControl`
+
+DETAILS:
+**Introduced** in GitLab 17.9.
+**Status**: Experiment.
+
+Input type: `CreateComplianceRequirementsControlInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `complianceRequirementId` | [`ComplianceManagementComplianceFrameworkComplianceRequirementID!`](#compliancemanagementcomplianceframeworkcompliancerequirementid) | Global ID of the compliance requirement of the new control. |
+| `params` | [`ComplianceRequirementsControlInput!`](#compliancerequirementscontrolinput) | Parameters to create the compliance requirement control. |
+
+#### 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. |
+| `requirementsControl` | [`ComplianceRequirementsControl`](#compliancerequirementscontrol) | Created compliance requirements control. |
+
### `Mutation.createContainerProtectionRepositoryRule`
Creates a repository protection rule to restrict access to a project's container registry.
@@ -13368,6 +13392,29 @@ The edge type for [`ComplianceRequirement`](#compliancerequirement).
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`ComplianceRequirement`](#compliancerequirement) | The item at the end of the edge. |
+#### `ComplianceRequirementsControlConnection`
+
+The connection type for [`ComplianceRequirementsControl`](#compliancerequirementscontrol).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `edges` | [`[ComplianceRequirementsControlEdge]`](#compliancerequirementscontroledge) | A list of edges. |
+| `nodes` | [`[ComplianceRequirementsControl]`](#compliancerequirementscontrol) | A list of nodes. |
+| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `ComplianceRequirementsControlEdge`
+
+The edge type for [`ComplianceRequirementsControl`](#compliancerequirementscontrol).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| `node` | [`ComplianceRequirementsControl`](#compliancerequirementscontrol) | The item at the end of the edge. |
+
#### `ComplianceStandardsAdherenceConnection`
The connection type for [`ComplianceStandardsAdherence`](#compliancestandardsadherence).
@@ -21699,6 +21746,7 @@ Represents a ComplianceRequirement associated with a ComplianceFramework.
| Name | Type | Description |
| ---- | ---- | ----------- |
+| `complianceRequirementsControls` | [`ComplianceRequirementsControlConnection`](#compliancerequirementscontrolconnection) | Compliance controls of the compliance requirement. (see [Connections](#connections)) |
| `controlExpression` | [`String`](#string) | Control expression of the compliance requirement. |
| `description` | [`String!`](#string) | Description of the compliance requirement. |
| `id` | [`ID!`](#id) | Compliance requirement ID. |
@@ -21715,6 +21763,19 @@ Lists down all the possible types of requirement controls.
| ---- | ---- | ----------- |
| `controlExpressions` | [`[ControlExpression!]!`](#controlexpression) | List of requirement controls. |
+### `ComplianceRequirementsControl`
+
+Represents a ComplianceRequirementsControl associated with a ComplianceRequirement.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `controlType` | [`String!`](#string) | Type of the compliance control. |
+| `expression` | [`String`](#string) | Expression of the compliance control. |
+| `id` | [`ID!`](#id) | Compliance requirements control ID. |
+| `name` | [`String!`](#string) | Name of the compliance control. |
+
### `ComplianceStandardsAdherence`
Compliance standards adherence for a project.
@@ -44758,6 +44819,15 @@ Attributes for defining a CI/CD variable.
| `description` | [`String`](#string) | New description for the compliance requirement. |
| `name` | [`String`](#string) | New name for the compliance requirement. |
+### `ComplianceRequirementsControlInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `expression` | [`String`](#string) | Expression of the compliance control. |
+| `name` | [`String`](#string) | New name for the compliance requirement control. |
+
### `ComplianceStandardsAdherenceInput`
#### Arguments
diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md
index 4feaf5ffe549178327c36f1d5a6b6dc275398e69..b33cf8caa24a1eb6b70161612c35066fdd65d252 100644
--- a/doc/user/compliance/audit_event_types.md
+++ b/doc/user/compliance/audit_event_types.md
@@ -148,6 +148,7 @@ Audit event types belong to the following product categories.
| [`create_compliance_framework`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74292) | A compliance framework is successfully created | **{check-circle}** Yes | GitLab [14.6](https://gitlab.com/gitlab-org/gitlab/-/issues/340649) | Group |
| [`create_status_check`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84624) | An external status check is created | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/355805) | Project |
| [`created_compliance_requirement`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/169485) | A requirement is added to a compliance framework | **{check-circle}** Yes | GitLab [17.6](https://gitlab.com/gitlab-org/gitlab/-/issues/470695) | Group |
+| [`created_compliance_requirement_control`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/177557) | A control is added to a compliance requirement. | **{check-circle}** Yes | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/512381) | Group |
| [`delete_status_check`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/84624) | An external status check is deleted | **{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 | **{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 | **{check-circle}** Yes | GitLab [17.7](https://gitlab.com/gitlab-org/gitlab/-/issues/470695) | Group |
diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb
index 55f115170559ec64932a9064f87739a97dfb93de..0b860a589b086a8dfd5573e124c29440a0a9321a 100644
--- a/ee/app/graphql/ee/types/mutation_type.rb
+++ b/ee/app/graphql/ee/types/mutation_type.rb
@@ -265,6 +265,8 @@ def self.authorization_scopes
experiment: { milestone: '17.7' }
mount_mutation ::Mutations::Ai::SelfHostedModels::ConnectionCheck,
experiment: { milestone: '17.7' }
+ mount_mutation ::Mutations::ComplianceManagement::ComplianceFramework::ComplianceRequirementsControls::Create,
+ experiment: { milestone: '17.9' }
prepend(Types::DeprecatedMutations)
end
diff --git a/ee/app/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/create.rb b/ee/app/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/create.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8b15f9ed18c6896830cd2ae45119fa434dacadea
--- /dev/null
+++ b/ee/app/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/create.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ComplianceManagement
+ module ComplianceFramework
+ module ComplianceRequirementsControls
+ class Create < BaseMutation
+ graphql_name 'CreateComplianceRequirementsControl'
+
+ authorize :admin_compliance_framework
+
+ field :requirements_control,
+ Types::ComplianceManagement::ComplianceRequirementsControlType,
+ null: true,
+ description: 'Created compliance requirements control.'
+
+ argument :compliance_requirement_id,
+ ::Types::GlobalIDType[::ComplianceManagement::ComplianceFramework::ComplianceRequirement],
+ required: true,
+ description: 'Global ID of the compliance requirement of the new control.'
+
+ argument :params, Types::ComplianceManagement::ComplianceRequirementsControlInputType,
+ required: true,
+ description: 'Parameters to create the compliance requirement control.'
+
+ def resolve(args)
+ requirement = authorized_find!(id: args[:compliance_requirement_id])
+
+ service = ::ComplianceManagement::ComplianceFramework::ComplianceRequirementsControls::CreateService.new(
+ requirement: requirement,
+ params: args[:params].to_h,
+ current_user: current_user
+ ).execute
+
+ service.success? ? success(service) : error(service)
+ end
+
+ private
+
+ def success(service)
+ { requirements_control: service.payload[:control], errors: [] }
+ end
+
+ def error(service)
+ errors = [service.message]
+ model_errors = service.payload.try(:full_messages).to_a
+
+ { errors: (errors + model_errors).flatten }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/types/compliance_management/compliance_requirement_type.rb b/ee/app/graphql/types/compliance_management/compliance_requirement_type.rb
index b1ec0f358a1432f87907382b0512fb7e8c244d23..d86f2c5b62ea772d86362cf0031a2e45ef3d647c 100644
--- a/ee/app/graphql/types/compliance_management/compliance_requirement_type.rb
+++ b/ee/app/graphql/types/compliance_management/compliance_requirement_type.rb
@@ -27,6 +27,11 @@ class ComplianceRequirementType < Types::BaseObject
field :requirement_type, GraphQL::Types::String,
null: false,
description: 'Type of the compliance requirement.'
+
+ field :compliance_requirements_controls,
+ ::Types::ComplianceManagement::ComplianceRequirementsControlType.connection_type,
+ null: true,
+ description: 'Compliance controls of the compliance requirement.'
end
end
end
diff --git a/ee/app/graphql/types/compliance_management/compliance_requirements_control_input_type.rb b/ee/app/graphql/types/compliance_management/compliance_requirements_control_input_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5628409e9274b4c215f68fda0023e8fbfeeddd6a
--- /dev/null
+++ b/ee/app/graphql/types/compliance_management/compliance_requirements_control_input_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ module ComplianceManagement
+ class ComplianceRequirementsControlInputType < BaseInputObject
+ graphql_name 'ComplianceRequirementsControlInput'
+
+ argument :name,
+ GraphQL::Types::String,
+ required: false,
+ description: 'New name for the compliance requirement control.'
+
+ argument :expression,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Expression of the compliance control.'
+ end
+ end
+end
diff --git a/ee/app/graphql/types/compliance_management/compliance_requirements_control_type.rb b/ee/app/graphql/types/compliance_management/compliance_requirements_control_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..50dda62457f4752f61f623d89196f03deb3ffc6a
--- /dev/null
+++ b/ee/app/graphql/types/compliance_management/compliance_requirements_control_type.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+# rubocop: disable Graphql/AuthorizeTypes -- because ComplianceRequirementsControlType is, and should only be, accessible via ComplianceRequirementType
+
+module Types
+ module ComplianceManagement
+ class ComplianceRequirementsControlType < Types::BaseObject
+ graphql_name 'ComplianceRequirementsControl'
+ description 'Represents a ComplianceRequirementsControl associated with a ComplianceRequirement'
+
+ field :id, GraphQL::Types::ID,
+ null: false,
+ description: 'Compliance requirements control ID.'
+
+ field :name, GraphQL::Types::String,
+ null: false,
+ description: 'Name of the compliance control.'
+
+ field :expression, GraphQL::Types::String,
+ null: true,
+ description: 'Expression of the compliance control.'
+
+ field :control_type, GraphQL::Types::String,
+ null: false,
+ description: 'Type of the compliance control.'
+ end
+ end
+end
+
+# rubocop: enable Graphql/AuthorizeTypes
diff --git a/ee/app/policies/compliance_management/compliance_framework/compliance_requirements_control_policy.rb b/ee/app/policies/compliance_management/compliance_framework/compliance_requirements_control_policy.rb
new file mode 100644
index 0000000000000000000000000000000000000000..96be3ca8ea65f58f8c03e81bff889d6ea94c6920
--- /dev/null
+++ b/ee/app/policies/compliance_management/compliance_framework/compliance_requirements_control_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module ComplianceManagement
+ module ComplianceFramework
+ class ComplianceRequirementsControlPolicy < BasePolicy
+ delegate { @subject.compliance_requirement.framework }
+ end
+ end
+end
diff --git a/ee/app/services/compliance_management/compliance_framework/compliance_requirements_controls/create_service.rb b/ee/app/services/compliance_management/compliance_framework/compliance_requirements_controls/create_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9d57268ccfb54dbbcb81bca780da7e99841ef8cd
--- /dev/null
+++ b/ee/app/services/compliance_management/compliance_framework/compliance_requirements_controls/create_service.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module ComplianceManagement
+ module ComplianceFramework
+ module ComplianceRequirementsControls
+ class CreateService < BaseService
+ attr_reader :params, :current_user, :requirement, :control
+
+ def initialize(requirement:, params:, current_user:)
+ @requirement = requirement
+ @params = params
+ @current_user = current_user
+ @control = ComplianceManagement::ComplianceFramework::ComplianceRequirementsControl.new
+ end
+
+ def execute
+ control.assign_attributes(
+ compliance_requirement: requirement,
+ namespace_id: requirement.namespace.id,
+ name: params[:name],
+ expression: params[:expression],
+ control_type: 'internal'
+ )
+
+ return ServiceResponse.error(message: _('Not permitted to create compliance control')) unless permitted?
+
+ return error unless control.save
+
+ audit_create
+ success
+ end
+
+ private
+
+ def permitted?
+ can?(current_user, :admin_compliance_framework, requirement.framework)
+ end
+
+ def success
+ ServiceResponse.success(payload: { control: control })
+ end
+
+ def audit_create
+ audit_context = {
+ name: 'created_compliance_requirement_control',
+ author: current_user,
+ scope: requirement.namespace,
+ target: control,
+ message: "Created compliance control #{control.name} for requirement #{requirement.name}",
+ additional_details: {
+ framework: requirement.framework.name,
+ control_type: control.control_type
+ }
+ }
+
+ ::Gitlab::Audit::Auditor.audit(audit_context)
+ end
+
+ def error
+ ServiceResponse.error(message: _('Failed to create compliance requirement control'), payload: control.errors)
+ end
+ end
+ end
+ end
+end
diff --git a/ee/config/audit_events/types/created_compliance_requirement_control.yml b/ee/config/audit_events/types/created_compliance_requirement_control.yml
new file mode 100644
index 0000000000000000000000000000000000000000..74104c35ae3a6dfae4a625504761b7bf733d6c66
--- /dev/null
+++ b/ee/config/audit_events/types/created_compliance_requirement_control.yml
@@ -0,0 +1,10 @@
+---
+name: created_compliance_requirement_control
+description: A control is added to a compliance requirement.
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/512381
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/177557
+milestone: '17.9'
+feature_category: compliance_management
+saved_to_database: true
+streamed: true
+scope: [Group]
diff --git a/ee/spec/graphql/ee/types/compliance_management/compliance_requirement_type_spec.rb b/ee/spec/graphql/ee/types/compliance_management/compliance_requirement_type_spec.rb
index 849eb582c4d6a360be65e2149b19fde6d10e2246..b8f15802a46dcab11f5069157486af38b7f9ffa7 100644
--- a/ee/spec/graphql/ee/types/compliance_management/compliance_requirement_type_spec.rb
+++ b/ee/spec/graphql/ee/types/compliance_management/compliance_requirement_type_spec.rb
@@ -11,6 +11,7 @@
description
controlExpression
requirementType
+ complianceRequirementsControls
]
it 'has the correct fields' do
diff --git a/ee/spec/graphql/ee/types/compliance_management/compliance_requirements_control_input_type_spec.rb b/ee/spec/graphql/ee/types/compliance_management/compliance_requirements_control_input_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..589775e0c872b73e395a0ae61f2f81e352822151
--- /dev/null
+++ b/ee/spec/graphql/ee/types/compliance_management/compliance_requirements_control_input_type_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Types::ComplianceManagement::ComplianceRequirementsControlInputType, feature_category: :compliance_management do
+ it { expect(described_class.graphql_name).to eq('ComplianceRequirementsControlInput') }
+
+ it { expect(described_class.arguments.keys).to match_array(%w[name expression]) }
+end
diff --git a/ee/spec/graphql/ee/types/compliance_management/compliance_requirements_control_type_spec.rb b/ee/spec/graphql/ee/types/compliance_management/compliance_requirements_control_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b03463cf76dec9b00d2cc46e1232d3ec45e97144
--- /dev/null
+++ b/ee/spec/graphql/ee/types/compliance_management/compliance_requirements_control_type_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ComplianceRequirementsControl'], feature_category: :compliance_management do
+ subject { described_class }
+
+ fields = %w[
+ id
+ name
+ expression
+ control_type
+ ]
+
+ it 'has the correct fields' do
+ is_expected.to have_graphql_fields(fields)
+ end
+end
diff --git a/ee/spec/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/create_spec.rb b/ee/spec/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a68f36c0b6876836e7cb2f2b501a96bbb1c67f28
--- /dev/null
+++ b/ee/spec/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/create_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::ComplianceManagement::ComplianceFramework::ComplianceRequirementsControls::Create,
+ feature_category: :compliance_management do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:namespace) { create(:group) }
+ let_it_be(:requirement) do
+ create(:compliance_requirement, framework: create(:compliance_framework, namespace: namespace))
+ end
+
+ let(:params) { valid_params }
+ let(:mutation) { described_class.new(object: nil, context: query_context, field: nil) }
+
+ subject(:mutate) { mutation.resolve(**params) }
+
+ describe '#resolve' do
+ shared_examples 'resource not available' do
+ it 'raises error' do
+ expect { mutate }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ before do
+ stub_licensed_features(custom_compliance_frameworks: true)
+ end
+
+ context 'when current_user is not group namespace owner' do
+ it_behaves_like 'resource not available'
+ end
+
+ context 'when current_user is group owner' do
+ before_all do
+ namespace.add_owner(current_user)
+ end
+
+ context 'when all arguments are valid' do
+ it 'creates a new compliance requirement control' do
+ expect { mutate }.to change { requirement.compliance_requirements_controls.count }.by 1
+ end
+ end
+
+ context 'when control parameters are invalid' do
+ subject(:mutate) { mutation.resolve(**invalid_name_params) }
+
+ it 'does not create a new compliance control' do
+ expect { mutate }.not_to change { requirement.compliance_requirements_controls.count }
+ end
+
+ it 'returns error for name' do
+ expect(mutate[:errors]).to include "Name can't be blank"
+ end
+ end
+ end
+
+ context 'when current_user is personal namespace owner' do
+ let_it_be(:namespace) { create(:user_namespace) }
+
+ let(:current_user) { namespace.owner }
+
+ context 'when control parameters are valid' do
+ it_behaves_like 'resource not available'
+ end
+ end
+ end
+
+ private
+
+ def valid_params
+ {
+ compliance_requirement_id: requirement.to_gid,
+ params: {
+ name: 'minimum_approvals_required_2',
+ expression: control_expression
+ }
+ }
+ end
+
+ def invalid_name_params
+ {
+ compliance_requirement_id: requirement.to_gid,
+ params: {
+ name: '',
+ expression: control_expression
+ }
+ }
+ end
+
+ def control_expression
+ {
+ operator: "=",
+ field: "minimum_approvals_required",
+ value: 2
+ }.to_json
+ end
+end
diff --git a/ee/spec/requests/api/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/create_spec.rb b/ee/spec/requests/api/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..402c14243b60769ed23361060234358e55aa8025
--- /dev/null
+++ b/ee/spec/requests/api/graphql/mutations/compliance_management/compliance_framework/compliance_requirements_controls/create_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create a Compliance Requirement Control', feature_category: :compliance_management do
+ include GraphqlHelpers
+
+ let_it_be(:namespace) { create(:group) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:requirement) do
+ create(:compliance_requirement, framework: create(:compliance_framework, namespace: namespace))
+ end
+
+ let(:mutation) do
+ graphql_mutation(
+ :create_compliance_requirements_control,
+ compliance_requirement_id: requirement.to_gid,
+ params: {
+ name: 'minimum_approvals_required_2',
+ expression: control_expression
+ }
+ )
+ end
+
+ subject(:mutate) { post_graphql_mutation(mutation, current_user: current_user) }
+
+ def mutation_response
+ graphql_mutation_response(:create_compliance_requirements_control)
+ end
+
+ shared_examples 'a mutation that creates a compliance requirement control' do
+ it 'creates a new compliance requirement control' do
+ expect { mutate }.to change { requirement.compliance_requirements_controls.count }.by 1
+ end
+
+ it 'returns the newly created requirement control', :aggregate_failures do
+ mutate
+
+ expect(mutation_response['requirementsControl']['name']).to eq 'minimum_approvals_required_2'
+ expect(mutation_response['requirementsControl']['expression']).to eq control_expression
+ expect(mutation_response['requirementsControl']['controlType']).to eq 'internal'
+ end
+ end
+
+ context 'when framework feature is unlicensed' do
+ before do
+ stub_licensed_features(custom_compliance_frameworks: false)
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when feature is licensed' do
+ before do
+ stub_licensed_features(custom_compliance_frameworks: true, evaluate_group_level_compliance_pipeline: true)
+ end
+
+ context 'when current_user is group owner' do
+ before_all do
+ namespace.add_owner(current_user)
+ end
+
+ it_behaves_like 'a mutation that creates a compliance requirement control'
+ end
+
+ context 'when current_user is not a group owner' do
+ context 'when current_user is group owner' do
+ before_all do
+ namespace.add_maintainer(current_user)
+ end
+
+ it 'does not create a new compliance requirement control' do
+ expect { mutate }.not_to change { requirement.compliance_requirements_controls.count }
+ end
+
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+ end
+ end
+
+ def control_expression
+ {
+ operator: "=",
+ field: "minimum_approvals_required",
+ value: 2
+ }.to_json
+ end
+end
diff --git a/ee/spec/services/compliance_management/compliance_framework/compliance_requirements_controls/create_service_spec.rb b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements_controls/create_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ffe0775994297bc477961c55cbc65dd4d0630efe
--- /dev/null
+++ b/ee/spec/services/compliance_management/compliance_framework/compliance_requirements_controls/create_service_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ComplianceManagement::ComplianceFramework::ComplianceRequirementsControls::CreateService,
+ feature_category: :compliance_management do
+ let_it_be_with_refind(:namespace) { create(:group) }
+ let_it_be(:current_user) { create(:user, owner_of: namespace) }
+ let_it_be(:requirement) do
+ create(:compliance_requirement, framework: create(:compliance_framework, namespace: namespace))
+ end
+
+ let(:params) do
+ {
+ name: 'minimum_approvals_required_2',
+ expression: control_expression
+ }
+ end
+
+ context 'when custom_compliance_frameworks is disabled' do
+ before do
+ stub_licensed_features(custom_compliance_frameworks: false)
+ end
+
+ subject(:control_creator) do
+ described_class.new(requirement: requirement, params: params, current_user: current_user)
+ end
+
+ it 'does not create a new compliance control' do
+ expect { control_creator.execute }.not_to change { requirement.compliance_requirements_controls.count }
+ end
+
+ it 'responds with an error message' do
+ expect(control_creator.execute.message).to eq('Not permitted to create compliance control')
+ end
+ end
+
+ context 'when custom_compliance_frameworks is enabled' do
+ before do
+ stub_licensed_features(custom_compliance_frameworks: true)
+ end
+
+ context 'when using invalid parameters' do
+ subject(:control_creator) do
+ described_class.new(requirement: requirement, params: params.except(:name), current_user: current_user)
+ end
+
+ let(:response) { control_creator.execute }
+
+ it 'responds with an error service response' do
+ expect(response.success?).to be_falsey
+ expect(response.payload.messages[:name]).to contain_exactly "can't be blank"
+ end
+ end
+
+ context 'when creating a compliance control for a namespace that current_user is not the owner of' do
+ subject(:control_creator) do
+ described_class.new(requirement: requirement, params: params, current_user: create(:user))
+ end
+
+ it 'responds with an error service response' do
+ expect(control_creator.execute.success?).to be false
+ end
+
+ it 'does not create a new compliance control' do
+ expect { control_creator.execute }.not_to change { requirement.compliance_requirements_controls.count }
+ end
+ end
+
+ context 'when using parameters for a valid compliance control' do
+ subject(:control_creator) do
+ described_class.new(requirement: requirement, params: params, current_user: current_user)
+ end
+
+ it 'audits the changes' do
+ expect { control_creator.execute }
+ .to change { AuditEvent.where("details LIKE ?", "%created_compliance_requirement_control%").count }.by(1)
+ end
+
+ it 'creates a new compliance control' do
+ expect { control_creator.execute }.to change { requirement.compliance_requirements_controls.count }.by(1)
+ end
+
+ it 'responds with a successful service response' do
+ expect(control_creator.execute.success?).to be true
+ end
+
+ it 'has the expected attributes' do
+ control = control_creator.execute.payload[:control]
+
+ expect(control.attributes).to include(
+ "name" => "minimum_approvals_required_2",
+ "compliance_requirement_id" => requirement.id,
+ "namespace_id" => namespace.id,
+ "expression" => control_expression,
+ "control_type" => "internal"
+ )
+ end
+ end
+ end
+
+ def control_expression
+ {
+ operator: "=",
+ field: "minimum_approvals_required",
+ value: 2
+ }.to_json
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index c051346b11ef858da031e0c8356ed36f057b4b66..72ec454eda8da77b722368baaef158cab615fe7d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -23802,6 +23802,9 @@ msgstr ""
msgid "Failed to create compliance requirement"
msgstr ""
+msgid "Failed to create compliance requirement control"
+msgstr ""
+
msgid "Failed to create framework"
msgstr ""
@@ -37820,6 +37823,9 @@ msgstr ""
msgid "Not found."
msgstr ""
+msgid "Not permitted to create compliance control"
+msgstr ""
+
msgid "Not permitted to destroy framework"
msgstr ""