diff --git a/app/graphql/types/container_registry/protection/tag_rule_access_level_enum.rb b/app/graphql/types/container_registry/protection/tag_rule_access_level_enum.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bcd4afdb9d0f695ad0b4a95755bf2e6892253f52
--- /dev/null
+++ b/app/graphql/types/container_registry/protection/tag_rule_access_level_enum.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+  module ContainerRegistry
+    module Protection
+      class TagRuleAccessLevelEnum < BaseEnum
+        graphql_name 'ContainerProtectionTagRuleAccessLevel'
+        description 'Access level of a container registry tag protection rule resource'
+
+        ::ContainerRegistry::Protection::TagRule::ACCESS_LEVELS.each_key do |access_level_key|
+          access_level_key = access_level_key.to_s
+
+          value access_level_key.upcase,
+            value: access_level_key,
+            experiment: { milestone: '17.8' },
+            description: "#{access_level_key.capitalize} access."
+        end
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/container_registry/protection/tag_rule_type.rb b/app/graphql/types/container_registry/protection/tag_rule_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7df702088feeb493534d207cd4d06946e74593b9
--- /dev/null
+++ b/app/graphql/types/container_registry/protection/tag_rule_type.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Types
+  module ContainerRegistry
+    module Protection
+      class TagRuleType < ::Types::BaseObject
+        graphql_name 'ContainerProtectionTagRule'
+        description 'A container repository tag 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::TagRule],
+          null: false,
+          experiment: { milestone: '17.8' },
+          description: 'ID of the container repository tag protection rule.'
+
+        field :tag_name_pattern,
+          GraphQL::Types::String,
+          null: false,
+          experiment: { milestone: '17.8' },
+          description:
+            'Container repository tag name pattern protected by the protection rule. ' \
+            'For example, `v1.*`. Wildcard character `*` allowed.'
+
+        # rubocop:disable GraphQL/ExtractType -- These are stored as separate fields
+        field :minimum_access_level_for_delete,
+          Types::ContainerRegistry::Protection::TagRuleAccessLevelEnum,
+          null: true,
+          experiment: { milestone: '17.8' },
+          description:
+            'Minimum GitLab access level required to delete container image tags from the container repository. ' \
+            'For example, `MAINTAINER`, `OWNER`, or `ADMIN`. ' \
+            'If the value is `nil`, the minimum access level is ignored. ' \
+            'Users with at least the Developer role can delete container image tags.'
+
+        field :minimum_access_level_for_push,
+          Types::ContainerRegistry::Protection::TagRuleAccessLevelEnum,
+          null: true,
+          experiment: { milestone: '17.8' },
+          description:
+            'Minimum GitLab access level required to push container image tags to the container repository. ' \
+            'For example, `MAINTAINER`, `OWNER`, or `ADMIN`. ' \
+            'If the value is `nil`, the minimum access level is ignored. ' \
+            'Users with at least the Developer role can push container image tags.'
+        # rubocop:enable GraphQL/ExtractType -- These are stored as user preferences
+      end
+    end
+  end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index b3af555665da4fd369c00bb00b2f80786d977318..6ce6a72c16ac305b2f6d65cde5c9ba1ccb78194b 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -529,6 +529,13 @@ class ProjectType < BaseObject
       experiment: { milestone: '16.10' },
       resolver: Resolvers::ProjectContainerRegistryProtectionRulesResolver
 
+    field :container_protection_tag_rules,
+      Types::ContainerRegistry::Protection::TagRuleType.connection_type,
+      null: true,
+      experiment: { milestone: '17.8' },
+      description: 'Container repository tag protection rules for the project. ' \
+        'Returns an empty array if the `container_registry_protected_tags` feature flag is disabled.'
+
     field :container_repositories, Types::ContainerRegistry::ContainerRepositoryType.connection_type,
       null: true,
       description: 'Container repositories of the project.',
@@ -939,6 +946,12 @@ def organization_edit_path
       )
     end
 
+    def container_protection_tag_rules
+      return [] unless Feature.enabled?(:container_registry_protected_tags, object)
+
+      object.container_registry_protection_tag_rules
+    end
+
     private
 
     def project
diff --git a/app/policies/container_registry/protection/tag_rule_policy.rb b/app/policies/container_registry/protection/tag_rule_policy.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a99e838d0eb996bb25dfb996889a3f8de7e3e2d9
--- /dev/null
+++ b/app/policies/container_registry/protection/tag_rule_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module ContainerRegistry
+  module Protection
+    class TagRulePolicy < BasePolicy
+      delegate { @subject.project }
+    end
+  end
+end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index c829155a2695ec05e1dbc805ec5096e1cedfc53b..11e3f7bef3288f128c0266899f5d6b04a0c1ba44 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -13269,6 +13269,29 @@ The edge type for [`ContainerProtectionRepositoryRule`](#containerprotectionrepo
 | <a id="containerprotectionrepositoryruleedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
 | <a id="containerprotectionrepositoryruleedgenode"></a>`node` | [`ContainerProtectionRepositoryRule`](#containerprotectionrepositoryrule) | The item at the end of the edge. |
 
+#### `ContainerProtectionTagRuleConnection`
+
+The connection type for [`ContainerProtectionTagRule`](#containerprotectiontagrule).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="containerprotectiontagruleconnectionedges"></a>`edges` | [`[ContainerProtectionTagRuleEdge]`](#containerprotectiontagruleedge) | A list of edges. |
+| <a id="containerprotectiontagruleconnectionnodes"></a>`nodes` | [`[ContainerProtectionTagRule]`](#containerprotectiontagrule) | A list of nodes. |
+| <a id="containerprotectiontagruleconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
+
+#### `ContainerProtectionTagRuleEdge`
+
+The edge type for [`ContainerProtectionTagRule`](#containerprotectiontagrule).
+
+##### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="containerprotectiontagruleedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
+| <a id="containerprotectiontagruleedgenode"></a>`node` | [`ContainerProtectionTagRule`](#containerprotectiontagrule) | The item at the end of the edge. |
+
 #### `ContainerRepositoryConnection`
 
 The connection type for [`ContainerRepository`](#containerrepository).
@@ -21504,6 +21527,19 @@ A container repository protection rule designed to prevent users with a certain
 | <a id="containerprotectionrepositoryruleminimumaccesslevelforpush"></a>`minimumAccessLevelForPush` **{warning-solid}** | [`ContainerProtectionRepositoryRuleAccessLevel`](#containerprotectionrepositoryruleaccesslevel) | **Introduced** in GitLab 16.6. **Status**: Experiment. Minimum GitLab access level required to push container images to the container repository. For example, `MAINTAINER`, `OWNER`, or `ADMIN`. If the value is `nil`, the minimum access level is ignored. Users with at least the Developer role can push container images. |
 | <a id="containerprotectionrepositoryrulerepositorypathpattern"></a>`repositoryPathPattern` **{warning-solid}** | [`String!`](#string) | **Introduced** in GitLab 16.6. **Status**: Experiment. Container repository path pattern protected by the protection rule. For example, `my-project/my-container-*`. Wildcard character `*` allowed. |
 
+### `ContainerProtectionTagRule`
+
+A container repository tag protection rule designed to prevent users with a certain access level or lower from altering the container registry.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="containerprotectiontagruleid"></a>`id` **{warning-solid}** | [`ContainerRegistryProtectionTagRuleID!`](#containerregistryprotectiontagruleid) | **Introduced** in GitLab 17.8. **Status**: Experiment. ID of the container repository tag protection rule. |
+| <a id="containerprotectiontagruleminimumaccesslevelfordelete"></a>`minimumAccessLevelForDelete` **{warning-solid}** | [`ContainerProtectionTagRuleAccessLevel`](#containerprotectiontagruleaccesslevel) | **Introduced** in GitLab 17.8. **Status**: Experiment. Minimum GitLab access level required to delete container image tags from the container repository. For example, `MAINTAINER`, `OWNER`, or `ADMIN`. If the value is `nil`, the minimum access level is ignored. Users with at least the Developer role can delete container image tags. |
+| <a id="containerprotectiontagruleminimumaccesslevelforpush"></a>`minimumAccessLevelForPush` **{warning-solid}** | [`ContainerProtectionTagRuleAccessLevel`](#containerprotectiontagruleaccesslevel) | **Introduced** in GitLab 17.8. **Status**: Experiment. Minimum GitLab access level required to push container image tags to the container repository. For example, `MAINTAINER`, `OWNER`, or `ADMIN`. If the value is `nil`, the minimum access level is ignored. Users with at least the Developer role can push container image tags. |
+| <a id="containerprotectiontagruletagnamepattern"></a>`tagNamePattern` **{warning-solid}** | [`String!`](#string) | **Introduced** in GitLab 17.8. **Status**: Experiment. Container repository tag name pattern protected by the protection rule. For example, `v1.*`. Wildcard character `*` allowed. |
+
 ### `ContainerRepository`
 
 A container repository.
@@ -31462,6 +31498,7 @@ Project-level settings for product analytics provider.
 | <a id="projectcomponentusages"></a>`componentUsages` | [`CiCatalogResourceComponentUsageConnection`](#cicatalogresourcecomponentusageconnection) | Component(s) used by the project. (see [Connections](#connections)) |
 | <a id="projectcontainerexpirationpolicy"></a>`containerExpirationPolicy` **{warning-solid}** | [`ContainerExpirationPolicy`](#containerexpirationpolicy) | **Deprecated** in GitLab 17.5. Use `container_tags_expiration_policy`. |
 | <a id="projectcontainerprotectionrepositoryrules"></a>`containerProtectionRepositoryRules` **{warning-solid}** | [`ContainerProtectionRepositoryRuleConnection`](#containerprotectionrepositoryruleconnection) | **Introduced** in GitLab 16.10. **Status**: Experiment. Container protection rules for the project. |
+| <a id="projectcontainerprotectiontagrules"></a>`containerProtectionTagRules` **{warning-solid}** | [`ContainerProtectionTagRuleConnection`](#containerprotectiontagruleconnection) | **Introduced** in GitLab 17.8. **Status**: Experiment. Container repository tag protection rules for the project. Returns an empty array if the `container_registry_protected_tags` feature flag is disabled. |
 | <a id="projectcontainerregistryenabled"></a>`containerRegistryEnabled` | [`Boolean`](#boolean) | Indicates if Container Registry is enabled for the current user. |
 | <a id="projectcontainerrepositoriescount"></a>`containerRepositoriesCount` | [`Int!`](#int) | Number of container repositories in the project. |
 | <a id="projectcontainertagsexpirationpolicy"></a>`containerTagsExpirationPolicy` | [`ContainerTagsExpirationPolicy`](#containertagsexpirationpolicy) | Container tags expiration policy of the project. |
@@ -38833,6 +38870,16 @@ Access level of a container registry protection rule resource.
 | <a id="containerprotectionrepositoryruleaccesslevelmaintainer"></a>`MAINTAINER` **{warning-solid}** | **Introduced** in GitLab 16.6. **Status**: Experiment. Maintainer access. |
 | <a id="containerprotectionrepositoryruleaccesslevelowner"></a>`OWNER` **{warning-solid}** | **Introduced** in GitLab 16.6. **Status**: Experiment. Owner access. |
 
+### `ContainerProtectionTagRuleAccessLevel`
+
+Access level of a container registry tag protection rule resource.
+
+| Value | Description |
+| ----- | ----------- |
+| <a id="containerprotectiontagruleaccessleveladmin"></a>`ADMIN` **{warning-solid}** | **Introduced** in GitLab 17.8. **Status**: Experiment. Admin access. |
+| <a id="containerprotectiontagruleaccesslevelmaintainer"></a>`MAINTAINER` **{warning-solid}** | **Introduced** in GitLab 17.8. **Status**: Experiment. Maintainer access. |
+| <a id="containerprotectiontagruleaccesslevelowner"></a>`OWNER` **{warning-solid}** | **Introduced** in GitLab 17.8. **Status**: Experiment. Owner access. |
+
 ### `ContainerRepositoryCleanupStatus`
 
 Status of the tags cleanup of a container repository.
@@ -41920,6 +41967,12 @@ A `ContainerRegistryProtectionRuleID` is a global ID. It is encoded as a string.
 
 An example `ContainerRegistryProtectionRuleID` is: `"gid://gitlab/ContainerRegistry::Protection::Rule/1"`.
 
+### `ContainerRegistryProtectionTagRuleID`
+
+A `ContainerRegistryProtectionTagRuleID` is a global ID. It is encoded as a string.
+
+An example `ContainerRegistryProtectionTagRuleID` is: `"gid://gitlab/ContainerRegistry::Protection::TagRule/1"`.
+
 ### `ContainerRepositoryID`
 
 A `ContainerRepositoryID` is a global ID. It is encoded as a string.
diff --git a/spec/graphql/types/container_registry/protection/tag_rule_access_level_enum_spec.rb b/spec/graphql/types/container_registry/protection/tag_rule_access_level_enum_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..855375c9c8abcdf4936faf04ba26737d40020be2
--- /dev/null
+++ b/spec/graphql/types/container_registry/protection/tag_rule_access_level_enum_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ContainerProtectionTagRuleAccessLevel'], feature_category: :container_registry do
+  it 'exposes all options' do
+    expect(described_class.values.keys).to match_array(%w[MAINTAINER OWNER ADMIN])
+  end
+end
diff --git a/spec/graphql/types/container_registry/protection/tag_rule_type_spec.rb b/spec/graphql/types/container_registry/protection/tag_rule_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..823ce6d4294760d6b838453573fe1c63ea660501
--- /dev/null
+++ b/spec/graphql/types/container_registry/protection/tag_rule_type_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ContainerProtectionTagRule'], feature_category: :container_registry do
+  specify { expect(described_class.graphql_name).to eq('ContainerProtectionTagRule') }
+
+  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::TagRule]) }
+  end
+
+  describe 'tag_name_pattern' do
+    subject { described_class.fields['tagNamePattern'] }
+
+    it { is_expected.to have_non_null_graphql_type(GraphQL::Types::String) }
+  end
+
+  describe 'minimum_access_level_for_push' do
+    subject { described_class.fields['minimumAccessLevelForPush'] }
+
+    it { is_expected.to have_nullable_graphql_type(Types::ContainerRegistry::Protection::TagRuleAccessLevelEnum) }
+  end
+
+  describe 'minimum_access_level_for_delete' do
+    subject { described_class.fields['minimumAccessLevelForDelete'] }
+
+    it { is_expected.to have_nullable_graphql_type(Types::ContainerRegistry::Protection::TagRuleAccessLevelEnum) }
+  end
+end
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 6d8fc8552d24276c053a2eb21533ad3ce46c78e4..f6b008d29fdcc4eec66c1613f485ea1c8f4f0567 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -45,7 +45,7 @@
       incident_management_timeline_event_tags visible_forks inherited_ci_variables autocomplete_users
       ci_cd_settings detailed_import_status value_streams ml_models
       allows_multiple_merge_request_assignees allows_multiple_merge_request_reviewers is_forked
-      protectable_branches available_deploy_keys explore_catalog_path
+      protectable_branches available_deploy_keys explore_catalog_path container_protection_tag_rules
     ]
 
     expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/spec/requests/api/graphql/project/container_registry_protection_tag_rules_spec.rb b/spec/requests/api/graphql/project/container_registry_protection_tag_rules_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f31a422f9208266b471aa07ba888b9c7486b3b6e
--- /dev/null
+++ b/spec/requests/api/graphql/project/container_registry_protection_tag_rules_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting the container tag protection rules linked to a project', :aggregate_failures, feature_category: :container_registry do
+  include GraphqlHelpers
+
+  let_it_be_with_reload(:project) { create(:project) }
+  let_it_be(:user) { project.owner }
+
+  let(:query) do
+    graphql_query_for(
+      :project,
+      { full_path: project.full_path },
+      query_nodes(:containerProtectionTagRules, of: 'ContainerProtectionTagRule')
+    )
+  end
+
+  let(:protection_rules) { graphql_data_at(:project, :containerProtectionTagRules, :nodes) }
+
+  subject(:send_graqhql_query) { post_graphql(query, current_user: user) }
+
+  context 'with authorized user owner' do
+    before do
+      send_graqhql_query
+    end
+
+    context 'with container tag protection rule' do
+      let_it_be(:tag_protection_rule) { create(:container_registry_protection_tag_rule, project: project) }
+
+      it_behaves_like 'a working graphql query'
+
+      it 'returns exactly one containersProtectionTagRule' do
+        expect(protection_rules.count).to eq 1
+      end
+
+      it 'returns all container tag protection rule fields' do
+        expect(protection_rules).to include(
+          hash_including(
+            'tagNamePattern' => tag_protection_rule.tag_name_pattern,
+            'minimumAccessLevelForDelete' => 'MAINTAINER',
+            'minimumAccessLevelForPush' => 'MAINTAINER'
+          )
+        )
+      end
+    end
+
+    context 'without container tag protection rule' do
+      it_behaves_like 'a working graphql query'
+
+      it 'returns no containersProtectionTagRule' do
+        expect(protection_rules).to be_empty
+      end
+    end
+  end
+
+  context 'with unauthorized user' do
+    let_it_be(:user) { create(:user, developer_of: project) }
+
+    before do
+      send_graqhql_query
+    end
+
+    it_behaves_like 'a working graphql query'
+
+    it 'returns no container tag protection rules' do
+      expect(protection_rules).to be_empty
+    end
+  end
+
+  context "when feature flag ':container_registry_protected_tags' disabled" do
+    let_it_be(:tag_protection_rule) { create(:container_registry_protection_tag_rule, project: project) }
+
+    before do
+      stub_feature_flags(container_registry_protected_tags: false)
+
+      send_graqhql_query
+    end
+
+    it_behaves_like 'a working graphql query'
+
+    it 'returns no container tag protection rules' do
+      expect(protection_rules).to be_empty
+    end
+  end
+end