diff --git a/app/graphql/mutations/container_registry/protection/rule/create.rb b/app/graphql/mutations/container_registry/protection/rule/create.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cf8416480a27fd1df4cb175f59332976e8479435
--- /dev/null
+++ b/app/graphql/mutations/container_registry/protection/rule/create.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module Mutations
+  module ContainerRegistry
+    module Protection
+      module Rule
+        class Create < ::Mutations::BaseMutation
+          graphql_name 'CreateContainerRegistryProtectionRule'
+          description 'Creates a protection rule to restrict access to a project\'s container registry. ' \
+                      'Available only when feature flag `container_registry_protected_containers` is enabled.'
+
+          include FindsProject
+
+          authorize :admin_container_image
+
+          argument :project_path,
+            GraphQL::Types::ID,
+            required: true,
+            description: 'Full path of the project where a protection rule is located.'
+
+          argument :container_path_pattern,
+            GraphQL::Types::String,
+            required: true,
+            description:
+              'ContainerRegistryname protected by the protection rule. For example `@my-scope/my-container-*`. ' \
+              'Wildcard character `*` allowed.'
+
+          argument :push_protected_up_to_access_level,
+            Types::ContainerRegistry::Protection::RuleAccessLevelEnum,
+            required: true,
+            description:
+              'Max GitLab access level to prevent from pushing container images to the container registry. ' \
+              'For example `DEVELOPER`, `MAINTAINER`, `OWNER`.'
+
+          argument :delete_protected_up_to_access_level,
+            Types::ContainerRegistry::Protection::RuleAccessLevelEnum,
+            required: true,
+            description:
+              'Max GitLab access level to prevent from deleting container images in the container registry. ' \
+              'For example `DEVELOPER`, `MAINTAINER`, `OWNER`.'
+
+          field :container_registry_protection_rule,
+            Types::ContainerRegistry::Protection::RuleType,
+            null: true,
+            description: 'Container registry protection rule after mutation.'
+
+          def resolve(project_path:, **kwargs)
+            project = authorized_find!(project_path)
+
+            if Feature.disabled?(:container_registry_protected_containers, project)
+              raise_resource_not_available_error!("'container_registry_protected_containers' feature flag is disabled")
+            end
+
+            response = ::ContainerRegistry::Protection::CreateRuleService.new(project, current_user, kwargs).execute
+
+            { container_registry_protection_rule: response.payload[:container_registry_protection_rule],
+              errors: response.errors }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/container_registry/protection/rule_access_level_enum.rb b/app/graphql/types/container_registry/protection/rule_access_level_enum.rb
new file mode 100644
index 0000000000000000000000000000000000000000..31e8cbe2e49d3a19f69c3e8104e2d4f41d01472e
--- /dev/null
+++ b/app/graphql/types/container_registry/protection/rule_access_level_enum.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+  module ContainerRegistry
+    module Protection
+      class RuleAccessLevelEnum < BaseEnum
+        graphql_name 'ContainerRegistryProtectionRuleAccessLevel'
+        description 'Access level of a container registry protection rule resource'
+
+        ::ContainerRegistry::Protection::Rule.push_protected_up_to_access_levels.each_key do |access_level_key|
+          value access_level_key.upcase, value: access_level_key.to_s,
+            description: "#{access_level_key.capitalize} access."
+        end
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/container_registry/protection/rule_type.rb b/app/graphql/types/container_registry/protection/rule_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..387f0202d2d7342b8531f811cf8b31af8fe83301
--- /dev/null
+++ b/app/graphql/types/container_registry/protection/rule_type.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Types
+  module ContainerRegistry
+    module Protection
+      class RuleType < ::Types::BaseObject
+        graphql_name 'ContainerRegistryProtectionRule'
+        description 'A container registry protection rule designed to prevent users with a certain ' \
+                    'access level or lower from altering the container registry.'
+
+        authorize :admin_container_image
+
+        field :id,
+          ::Types::GlobalIDType[::ContainerRegistry::Protection::Rule],
+          null: false,
+          description: 'ID of the container registry protection rule.'
+
+        field :container_path_pattern,
+          GraphQL::Types::String,
+          null: false,
+          description:
+            'Container repository path pattern protected by the protection rule. ' \
+            'For example `@my-scope/my-container-*`. Wildcard character `*` allowed.'
+
+        field :push_protected_up_to_access_level,
+          Types::ContainerRegistry::Protection::RuleAccessLevelEnum,
+          null: false,
+          description:
+            'Max GitLab access level to prevent from pushing container images to the container registry. ' \
+            'For example `DEVELOPER`, `MAINTAINER`, `OWNER`.'
+
+        field :delete_protected_up_to_access_level,
+          Types::ContainerRegistry::Protection::RuleAccessLevelEnum,
+          null: false,
+          description:
+            'Max GitLab access level to prevent from pushing container images to the container registry. ' \
+            'For example `DEVELOPER`, `MAINTAINER`, `OWNER`.'
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 475066e727913831265632c5512be7279073fe21..4ff64d6f60c9a9575c2ea159e44534b3103c898f 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -134,6 +134,7 @@ class MutationType < BaseObject
     mount_mutation Mutations::DesignManagement::Move
     mount_mutation Mutations::DesignManagement::Update
     mount_mutation Mutations::ContainerExpirationPolicies::Update
+    mount_mutation Mutations::ContainerRegistry::Protection::Rule::Create, alpha: { milestone: '16.6' }
     mount_mutation Mutations::ContainerRepositories::Destroy
     mount_mutation Mutations::ContainerRepositories::DestroyTags
     mount_mutation Mutations::Ci::Catalog::Resources::Create, alpha: { milestone: '15.11' }
diff --git a/app/policies/container_registry/protection/rule_policy.rb b/app/policies/container_registry/protection/rule_policy.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4dc8dba3276974adc604e66a4d93d5770337caa7
--- /dev/null
+++ b/app/policies/container_registry/protection/rule_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+  module Protection
+    class RulePolicy < BasePolicy
+      delegate { @subject.project }
+    end
+  end
+end
diff --git a/app/services/container_registry/protection/create_rule_service.rb b/app/services/container_registry/protection/create_rule_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..34ec6f42b19107d1a0e838cf487a044f8e6e3eb9
--- /dev/null
+++ b/app/services/container_registry/protection/create_rule_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+  module Protection
+    class CreateRuleService < BaseService
+      ALLOWED_ATTRIBUTES = %i[
+        container_path_pattern
+        push_protected_up_to_access_level
+        delete_protected_up_to_access_level
+      ].freeze
+
+      def execute
+        unless can?(current_user, :admin_container_image, project)
+          error_message = _('Unauthorized to create a container registry protection rule')
+          return service_response_error(message: error_message)
+        end
+
+        container_registry_protection_rule =
+          project.container_registry_protection_rules.create(params.slice(*ALLOWED_ATTRIBUTES))
+
+        unless container_registry_protection_rule.persisted?
+          return service_response_error(message: container_registry_protection_rule.errors.full_messages.to_sentence)
+        end
+
+        ServiceResponse.success(payload: { container_registry_protection_rule: container_registry_protection_rule })
+      rescue StandardError => e
+        service_response_error(message: e.message)
+      end
+
+      private
+
+      def service_response_error(message:)
+        ServiceResponse.error(
+          message: message,
+          payload: { container_registry_protection_rule: nil }
+        )
+      end
+    end
+  end
+end
diff --git a/config/feature_flags/development/container_registry_protected_containers.yml b/config/feature_flags/development/container_registry_protected_containers.yml
new file mode 100644
index 0000000000000000000000000000000000000000..94305b7251b582576e96d47d682e96667720fe0e
--- /dev/null
+++ b/config/feature_flags/development/container_registry_protected_containers.yml
@@ -0,0 +1,8 @@
+---
+name: container_registry_protected_containers
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133527
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/429074
+milestone: '16.6'
+type: development
+group: group::container registry
+default_enabled: false
\ No newline at end of file
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 1a84a8729427cb110b67ff4c2b6d98764bdfffd8..68036bc4d83e8f67f356e4e04f7c9676582c6f9a 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -2290,6 +2290,34 @@ Input type: `CreateComplianceFrameworkInput`
 | <a id="mutationcreatecomplianceframeworkerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
 | <a id="mutationcreatecomplianceframeworkframework"></a>`framework` | [`ComplianceFramework`](#complianceframework) | Created compliance framework. |
 
+### `Mutation.createContainerRegistryProtectionRule`
+
+Creates a protection rule to restrict access to a project's container registry. Available only when feature flag `container_registry_protected_containers` is enabled.
+
+WARNING:
+**Introduced** in 16.6.
+This feature is an Experiment. It can be changed or removed at any time.
+
+Input type: `CreateContainerRegistryProtectionRuleInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationcreatecontainerregistryprotectionruleclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationcreatecontainerregistryprotectionrulecontainerpathpattern"></a>`containerPathPattern` | [`String!`](#string) | ContainerRegistryname protected by the protection rule. For example `@my-scope/my-container-*`. Wildcard character `*` allowed. |
+| <a id="mutationcreatecontainerregistryprotectionruledeleteprotecteduptoaccesslevel"></a>`deleteProtectedUpToAccessLevel` | [`ContainerRegistryProtectionRuleAccessLevel!`](#containerregistryprotectionruleaccesslevel) | Max GitLab access level to prevent from deleting container images in the container registry. For example `DEVELOPER`, `MAINTAINER`, `OWNER`. |
+| <a id="mutationcreatecontainerregistryprotectionruleprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project where a protection rule is located. |
+| <a id="mutationcreatecontainerregistryprotectionrulepushprotecteduptoaccesslevel"></a>`pushProtectedUpToAccessLevel` | [`ContainerRegistryProtectionRuleAccessLevel!`](#containerregistryprotectionruleaccesslevel) | Max GitLab access level to prevent from pushing container images to the container registry. For example `DEVELOPER`, `MAINTAINER`, `OWNER`. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="mutationcreatecontainerregistryprotectionruleclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| <a id="mutationcreatecontainerregistryprotectionrulecontainerregistryprotectionrule"></a>`containerRegistryProtectionRule` | [`ContainerRegistryProtectionRule`](#containerregistryprotectionrule) | Container registry protection rule after mutation. |
+| <a id="mutationcreatecontainerregistryprotectionruleerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+
 ### `Mutation.createCustomEmoji`
 
 WARNING:
@@ -15588,6 +15616,19 @@ A tag expiration policy designed to keep only the images that matter most.
 | <a id="containerexpirationpolicyolderthan"></a>`olderThan` | [`ContainerExpirationPolicyOlderThanEnum`](#containerexpirationpolicyolderthanenum) | Tags older that this will expire. |
 | <a id="containerexpirationpolicyupdatedat"></a>`updatedAt` | [`Time!`](#time) | Timestamp of when the container expiration policy was updated. |
 
+### `ContainerRegistryProtectionRule`
+
+A container registry protection rule designed to prevent users with a certain access level or lower from altering the container registry.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="containerregistryprotectionrulecontainerpathpattern"></a>`containerPathPattern` | [`String!`](#string) | Container repository path pattern protected by the protection rule. For example `@my-scope/my-container-*`. Wildcard character `*` allowed. |
+| <a id="containerregistryprotectionruledeleteprotecteduptoaccesslevel"></a>`deleteProtectedUpToAccessLevel` | [`ContainerRegistryProtectionRuleAccessLevel!`](#containerregistryprotectionruleaccesslevel) | Max GitLab access level to prevent from pushing container images to the container registry. For example `DEVELOPER`, `MAINTAINER`, `OWNER`. |
+| <a id="containerregistryprotectionruleid"></a>`id` | [`ContainerRegistryProtectionRuleID!`](#containerregistryprotectionruleid) | ID of the container registry protection rule. |
+| <a id="containerregistryprotectionrulepushprotecteduptoaccesslevel"></a>`pushProtectedUpToAccessLevel` | [`ContainerRegistryProtectionRuleAccessLevel!`](#containerregistryprotectionruleaccesslevel) | Max GitLab access level to prevent from pushing container images to the container registry. For example `DEVELOPER`, `MAINTAINER`, `OWNER`. |
+
 ### `ContainerRepository`
 
 A container repository.
@@ -28088,6 +28129,16 @@ Values for sorting contacts.
 | <a id="containerexpirationpolicyolderthanenumsixty_days"></a>`SIXTY_DAYS` | 60 days until tags are automatically removed. |
 | <a id="containerexpirationpolicyolderthanenumthirty_days"></a>`THIRTY_DAYS` | 30 days until tags are automatically removed. |
 
+### `ContainerRegistryProtectionRuleAccessLevel`
+
+Access level of a container registry protection rule resource.
+
+| Value | Description |
+| ----- | ----------- |
+| <a id="containerregistryprotectionruleaccessleveldeveloper"></a>`DEVELOPER` | Developer access. |
+| <a id="containerregistryprotectionruleaccesslevelmaintainer"></a>`MAINTAINER` | Maintainer access. |
+| <a id="containerregistryprotectionruleaccesslevelowner"></a>`OWNER` | Owner access. |
+
 ### `ContainerRepositoryCleanupStatus`
 
 Status of the tags cleanup of a container repository.
@@ -30369,6 +30420,12 @@ A `ComplianceManagementFrameworkID` is a global ID. It is encoded as a string.
 
 An example `ComplianceManagementFrameworkID` is: `"gid://gitlab/ComplianceManagement::Framework/1"`.
 
+### `ContainerRegistryProtectionRuleID`
+
+A `ContainerRegistryProtectionRuleID` is a global ID. It is encoded as a string.
+
+An example `ContainerRegistryProtectionRuleID` is: `"gid://gitlab/ContainerRegistry::Protection::Rule/1"`.
+
 ### `ContainerRepositoryID`
 
 A `ContainerRepositoryID` is a global ID. It is encoded as a string.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index e6a6625e9fa7836a982595ce42823f06bd154c2c..f32a972a191ca1950218c9144fca7d1c1e5b008d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -50691,6 +50691,9 @@ msgstr ""
 msgid "Unauthorized to access the cluster agent in this project"
 msgstr ""
 
+msgid "Unauthorized to create a container registry protection rule"
+msgstr ""
+
 msgid "Unauthorized to create a package protection rule"
 msgstr ""
 
diff --git a/spec/graphql/types/container_registry/protection/rule_access_level_enum_spec.rb b/spec/graphql/types/container_registry/protection/rule_access_level_enum_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..295401f89f95eadcdd5bdcde4d913151265c974c
--- /dev/null
+++ b/spec/graphql/types/container_registry/protection/rule_access_level_enum_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ContainerRegistryProtectionRuleAccessLevel'], feature_category: :container_registry do
+  it 'exposes all options' do
+    expect(described_class.values.keys).to match_array(%w[DEVELOPER MAINTAINER OWNER])
+  end
+end
diff --git a/spec/graphql/types/container_registry/protection/rule_type_spec.rb b/spec/graphql/types/container_registry/protection/rule_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..58b53af80fb26c0a60593a8c9c1cdbd3005138db
--- /dev/null
+++ b/spec/graphql/types/container_registry/protection/rule_type_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ContainerRegistryProtectionRule'], feature_category: :container_registry do
+  specify { expect(described_class.graphql_name).to eq('ContainerRegistryProtectionRule') }
+
+  specify { expect(described_class.description).to be_present }
+
+  specify { expect(described_class).to require_graphql_authorizations(:admin_container_image) }
+
+  describe 'id' do
+    subject { described_class.fields['id'] }
+
+    it { is_expected.to have_non_null_graphql_type(::Types::GlobalIDType[::ContainerRegistry::Protection::Rule]) }
+  end
+
+  describe 'container_path_pattern' do
+    subject { described_class.fields['containerPathPattern'] }
+
+    it { is_expected.to have_non_null_graphql_type(GraphQL::Types::String) }
+  end
+
+  describe 'push_protected_up_to_access_level' do
+    subject { described_class.fields['pushProtectedUpToAccessLevel'] }
+
+    it { is_expected.to have_non_null_graphql_type(Types::ContainerRegistry::Protection::RuleAccessLevelEnum) }
+  end
+
+  describe 'delete_protected_up_to_access_level' do
+    subject { described_class.fields['deleteProtectedUpToAccessLevel'] }
+
+    it { is_expected.to have_non_null_graphql_type(Types::ContainerRegistry::Protection::RuleAccessLevelEnum) }
+  end
+end
diff --git a/spec/requests/api/graphql/mutations/container_registry/protection/rule/create_spec.rb b/spec/requests/api/graphql/mutations/container_registry/protection/rule/create_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0c708c3dc41483af8e10a88b33a2ec222c36ea8f
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/container_registry/protection/rule/create_spec.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Creating the container registry protection rule', :aggregate_failures, feature_category: :container_registry do
+  include GraphqlHelpers
+
+  let_it_be(:project) { create(:project) }
+  let_it_be(:user) { create(:user, maintainer_projects: [project]) }
+
+  let(:container_registry_protection_rule_attributes) do
+    build_stubbed(:container_registry_protection_rule, project: project)
+  end
+
+  let(:kwargs) do
+    {
+      project_path: project.full_path,
+      container_path_pattern: container_registry_protection_rule_attributes.container_path_pattern,
+      push_protected_up_to_access_level: 'MAINTAINER',
+      delete_protected_up_to_access_level: 'MAINTAINER'
+    }
+  end
+
+  let(:mutation) do
+    graphql_mutation(:create_container_registry_protection_rule, kwargs,
+      <<~QUERY
+      containerRegistryProtectionRule {
+        id
+        containerPathPattern
+      }
+      clientMutationId
+      errors
+      QUERY
+    )
+  end
+
+  let(:mutation_response) { graphql_mutation_response(:create_container_registry_protection_rule) }
+
+  subject { post_graphql_mutation(mutation, current_user: user) }
+
+  shared_examples 'a successful response' do
+    it { subject.tap { expect_graphql_errors_to_be_empty } }
+
+    it do
+      subject
+
+      expect(mutation_response).to include(
+        'errors' => be_blank,
+        'containerRegistryProtectionRule' => {
+          'id' => be_present,
+          'containerPathPattern' => kwargs[:container_path_pattern]
+        }
+      )
+    end
+
+    it 'creates container registry protection rule in the database' do
+      expect { subject }.to change { ::ContainerRegistry::Protection::Rule.count }.by(1)
+
+      expect(::ContainerRegistry::Protection::Rule.where(project: project,
+        container_path_pattern: kwargs[:container_path_pattern])).to exist
+    end
+  end
+
+  shared_examples 'an erroneous response' do
+    it { expect { subject }.not_to change { ::ContainerRegistry::Protection::Rule.count } }
+  end
+
+  it_behaves_like 'a successful response'
+
+  context 'with invalid input fields `pushProtectedUpToAccessLevel` and `deleteProtectedUpToAccessLevel`' do
+    let(:kwargs) do
+      super().merge(
+        push_protected_up_to_access_level: 'UNKNOWN_ACCESS_LEVEL',
+        delete_protected_up_to_access_level: 'UNKNOWN_ACCESS_LEVEL'
+      )
+    end
+
+    it_behaves_like 'an erroneous response'
+
+    it {
+      subject
+
+      expect_graphql_errors_to_include([/pushProtectedUpToAccessLevel/, /deleteProtectedUpToAccessLevel/])
+    }
+  end
+
+  context 'with invalid input field `containerPathPattern`' do
+    let(:kwargs) do
+      super().merge(container_path_pattern: '')
+    end
+
+    it_behaves_like 'an erroneous response'
+
+    it { subject.tap { expect_graphql_errors_to_be_empty } }
+
+    it {
+      subject.tap do
+        expect(mutation_response['errors']).to eq ["Container path pattern can't be blank"]
+      end
+    }
+  end
+
+  context 'with existing containers protection rule' do
+    let_it_be(:existing_container_registry_protection_rule) do
+      create(:container_registry_protection_rule, project: project,
+        push_protected_up_to_access_level: Gitlab::Access::DEVELOPER)
+    end
+
+    context 'when container name pattern is slightly different' do
+      let(:kwargs) do
+        # The field `container_path_pattern` is unique; this is why we change the value in a minimum way
+        super().merge(
+          container_path_pattern: "#{existing_container_registry_protection_rule.container_path_pattern}-unique"
+        )
+      end
+
+      it_behaves_like 'a successful response'
+
+      it 'adds another container registry protection rule to the database' do
+        expect { subject }.to change { ::ContainerRegistry::Protection::Rule.count }.from(1).to(2)
+      end
+    end
+
+    context 'when field `container_path_pattern` is taken' do
+      let(:kwargs) do
+        super().merge(container_path_pattern: existing_container_registry_protection_rule.container_path_pattern,
+          push_protected_up_to_access_level: 'MAINTAINER')
+      end
+
+      it_behaves_like 'an erroneous response'
+
+      it { subject.tap { expect_graphql_errors_to_be_empty } }
+
+      it 'returns without error' do
+        subject
+
+        expect(mutation_response['errors']).to eq ['Container path pattern has already been taken']
+      end
+
+      it 'does not create new container protection rules' do
+        expect(::ContainerRegistry::Protection::Rule.where(project: project,
+          container_path_pattern: kwargs[:container_path_pattern],
+          push_protected_up_to_access_level: Gitlab::Access::MAINTAINER)).not_to exist
+      end
+    end
+  end
+
+  context 'when user does not have permission' do
+    let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+    let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } }
+    let_it_be(:guest) { create(:user).tap { |u| project.add_guest(u) } }
+    let_it_be(:anonymous) { create(:user) }
+
+    where(:user) do
+      [ref(:developer), ref(:reporter), ref(:guest), ref(:anonymous)]
+    end
+
+    with_them do
+      it_behaves_like 'an erroneous response'
+
+      it { subject.tap { expect_graphql_errors_to_include(/you don't have permission to perform this action/) } }
+    end
+  end
+
+  context "when feature flag ':container_registry_protected_containers' disabled" do
+    before do
+      stub_feature_flags(container_registry_protected_containers: false)
+    end
+
+    it_behaves_like 'an erroneous response'
+
+    it { subject.tap { expect(::ContainerRegistry::Protection::Rule.where(project: project)).not_to exist } }
+
+    it 'returns error of disabled feature flag' do
+      subject.tap do
+        expect_graphql_errors_to_include(/'container_registry_protected_containers' feature flag is disabled/)
+      end
+    end
+  end
+end
diff --git a/spec/services/container_registry/protection/create_rule_service_spec.rb b/spec/services/container_registry/protection/create_rule_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3c319caf25c820a3f31e0e3373979814a920ac75
--- /dev/null
+++ b/spec/services/container_registry/protection/create_rule_service_spec.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ContainerRegistry::Protection::CreateRuleService, '#execute', feature_category: :container_registry do
+  let_it_be(:project) { create(:project, :repository) }
+  let_it_be(:current_user) { create(:user, maintainer_projects: [project]) }
+
+  let(:service) { described_class.new(project, current_user, params) }
+  let(:params) { attributes_for(:container_registry_protection_rule) }
+
+  subject { service.execute }
+
+  shared_examples 'a successful service response' do
+    it { is_expected.to be_success }
+
+    it { is_expected.to have_attributes(errors: be_blank) }
+
+    it do
+      is_expected.to have_attributes(
+        payload: {
+          container_registry_protection_rule:
+            be_a(ContainerRegistry::Protection::Rule)
+              .and(have_attributes(
+                container_path_pattern: params[:container_path_pattern],
+                push_protected_up_to_access_level: params[:push_protected_up_to_access_level].to_s,
+                delete_protected_up_to_access_level: params[:delete_protected_up_to_access_level].to_s
+              ))
+        }
+      )
+    end
+
+    it 'creates a new container registry protection rule in the database' do
+      expect { subject }.to change { ContainerRegistry::Protection::Rule.count }.by(1)
+
+      expect(
+        ContainerRegistry::Protection::Rule.where(
+          project: project,
+          container_path_pattern: params[:container_path_pattern],
+          push_protected_up_to_access_level: params[:push_protected_up_to_access_level]
+        )
+      ).to exist
+    end
+  end
+
+  shared_examples 'an erroneous service response' do
+    it { is_expected.to be_error }
+    it { is_expected.to have_attributes(errors: be_present, payload: include(container_registry_protection_rule: nil)) }
+
+    it 'does not create a new container registry protection rule in the database' do
+      expect { subject }.not_to change { ContainerRegistry::Protection::Rule.count }
+    end
+
+    it 'does not create a container registry protection rule with the given params' do
+      subject
+
+      expect(
+        ContainerRegistry::Protection::Rule.where(
+          project: project,
+          container_path_pattern: params[:container_path_pattern],
+          push_protected_up_to_access_level: params[:push_protected_up_to_access_level]
+        )
+      ).not_to exist
+    end
+  end
+
+  it_behaves_like 'a successful service response'
+
+  context 'when fields are invalid' do
+    context 'when container_path_pattern is invalid' do
+      let(:params) { super().merge(container_path_pattern: '') }
+
+      it_behaves_like 'an erroneous service response'
+
+      it { is_expected.to have_attributes(message: match(/Container path pattern can't be blank/)) }
+    end
+
+    context 'when delete_protected_up_to_access_level is invalid' do
+      let(:params) { super().merge(delete_protected_up_to_access_level: 1000) }
+
+      it_behaves_like 'an erroneous service response'
+
+      it { is_expected.to have_attributes(message: match(/is not a valid delete_protected_up_to_access_level/)) }
+    end
+
+    context 'when push_protected_up_to_access_level is invalid' do
+      let(:params) { super().merge(push_protected_up_to_access_level: 1000) }
+
+      it_behaves_like 'an erroneous service response'
+
+      it { is_expected.to have_attributes(message: match(/is not a valid push_protected_up_to_access_level/)) }
+    end
+  end
+
+  context 'with existing container registry protection rule in the database' do
+    let_it_be_with_reload(:existing_container_registry_protection_rule) do
+      create(:container_registry_protection_rule, project: project)
+    end
+
+    context 'when container registry name pattern is slightly different' do
+      let(:params) do
+        super().merge(
+          # The field `container_path_pattern` is unique; this is why we change the value in a minimum way
+          container_path_pattern: "#{existing_container_registry_protection_rule.container_path_pattern}-unique",
+          push_protected_up_to_access_level:
+            existing_container_registry_protection_rule.push_protected_up_to_access_level
+        )
+      end
+
+      it_behaves_like 'a successful service response'
+    end
+
+    context 'when field `container_path_pattern` is taken' do
+      let(:params) do
+        super().merge(
+          container_path_pattern: existing_container_registry_protection_rule.container_path_pattern,
+          push_protected_up_to_access_level: :maintainer
+        )
+      end
+
+      it_behaves_like 'an erroneous service response'
+
+      it { is_expected.to have_attributes(errors: ['Container path pattern has already been taken']) }
+
+      it { expect { subject }.not_to change { existing_container_registry_protection_rule.updated_at } }
+    end
+  end
+
+  context 'with disallowed params' do
+    let(:params) { super().merge(project_id: 1, unsupported_param: 'unsupported_param_value') }
+
+    it_behaves_like 'a successful service response'
+  end
+
+  context 'with forbidden user access level (project developer role)' do
+    # Because of the access level hierarchy, we can assume that
+    # other access levels below developer role will also not be able to
+    # create container registry protection rules.
+    let_it_be(:current_user) { create(:user).tap { |u| project.add_developer(u) } }
+
+    it_behaves_like 'an erroneous service response'
+
+    it { is_expected.to have_attributes(message: match(/Unauthorized/)) }
+  end
+end