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