From c09a98436c7964d89d74d4b3364b6a530d0ac132 Mon Sep 17 00:00:00 2001 From: Gerardo Navarro Date: Sun, 23 Nov 2025 21:20:46 +0000 Subject: [PATCH 1/3] Add POST endpoint for container protection tag rules API Implements creation of container registry protection tag rules through the REST API at POST /api/v4/projects/:id/registry/ protection/tag/rules. This completes the read-write API surface for tag protection rules, building on the GET endpoint added in the previous commit. Technical changes: - Added POST endpoint delegating to CreateTagRuleService - Uses Grape validation for tag_name_pattern (required), minimum_access_level_for_push (required), and minimum_access_level_for_delete (required) - Both access levels required to match GraphQL API behavior - Enforces 5 rules per project maximum through the service - Returns 201 Created on success, 422 on validation errors - Comprehensive test coverage with 17 scenarios including validation errors, authorization, duplicates, and limits Both access levels are required parameters, aligning with the existing GraphQL mutation behavior and model validation that enforces both fields must be present. This ensures consistent API contracts across REST and GraphQL interfaces. Changelog: added --- doc/api/openapi/openapi_v2.yaml | 64 +++++++++ ...container_registry_protection_tag_rules.rb | 37 ++++++ ...iner_registry_protection_tag_rules_spec.rb | 122 +++++++++++++++++- 3 files changed, 220 insertions(+), 3 deletions(-) diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index d57a9c944b9259..1cd2eff32a25a8 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -30317,6 +30317,42 @@ paths: tags: - projects operationId: getApiV4ProjectsIdRegistryProtectionTagRules + post: + summary: Create a container protection tag rule for a project + description: This feature was introduced in GitLab 18.7 + produces: + - application/json + consumes: + - application/json + parameters: + - in: path + name: id + description: The ID or URL-encoded path of the project. + type: string + required: true + - name: postApiV4ProjectsIdRegistryProtectionTagRules + in: body + required: true + schema: + "$ref": "#/definitions/postApiV4ProjectsIdRegistryProtectionTagRules" + responses: + '201': + description: Create a container protection tag rule for a project + schema: + "$ref": "#/definitions/API_Entities_Projects_ContainerRegistry_Protection_TagRule" + '400': + description: Bad Request + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: Not Found + '422': + description: Unprocessable Entity + tags: + - projects + operationId: postApiV4ProjectsIdRegistryProtectionTagRules "/api/v4/projects/{id}/debian_distributions": post: summary: Create a Debian Distribution @@ -63062,6 +63098,34 @@ definitions: type: string example: maintainer description: API_Entities_Projects_ContainerRegistry_Protection_TagRule model + postApiV4ProjectsIdRegistryProtectionTagRules: + type: object + properties: + tag_name_pattern: + type: string + description: Container tag name pattern protected by the protection rule. + For example `v*-release`. Wildcard character `*` allowed. + minimum_access_level_for_push: + type: string + description: Minimum GitLab access level to allow to push container tags. + For example maintainer, owner or admin. + enum: + - maintainer + - owner + - admin + minimum_access_level_for_delete: + type: string + description: Minimum GitLab access level to allow to delete container tags. + For example maintainer, owner or admin. + enum: + - maintainer + - owner + - admin + required: + - tag_name_pattern + - minimum_access_level_for_push + - minimum_access_level_for_delete + description: Create a container protection tag rule for a project postApiV4ProjectsIdDebianDistributions: type: object properties: diff --git a/lib/api/project_container_registry_protection_tag_rules.rb b/lib/api/project_container_registry_protection_tag_rules.rb index 515bf18934f4bc..b2938ddc6c35ca 100644 --- a/lib/api/project_container_registry_protection_tag_rules.rb +++ b/lib/api/project_container_registry_protection_tag_rules.rb @@ -30,6 +30,43 @@ class ProjectContainerRegistryProtectionTagRules < ::API::Base present user_project.container_registry_protection_tag_rules.mutable, with: Entities::Projects::ContainerRegistry::Protection::TagRule end + + desc 'Create a container protection tag rule for a project' do + detail 'This feature was introduced in GitLab 18.7' + success Entities::Projects::ContainerRegistry::Protection::TagRule + failure [ + { code: 400, message: 'Bad Request' }, + { code: 401, message: 'Unauthorized' }, + { code: 403, message: 'Forbidden' }, + { code: 404, message: 'Not Found' }, + { code: 422, message: 'Unprocessable Entity' } + ] + tags %w[projects] + end + params do + requires :tag_name_pattern, type: String, + desc: 'Container tag name pattern protected by the protection rule. ' \ + 'For example `v*-release`. Wildcard character `*` allowed.' + requires :minimum_access_level_for_push, type: String, + values: ContainerRegistry::Protection::TagRule.minimum_access_level_for_pushes.keys, + desc: 'Minimum GitLab access level to allow to push container tags. ' \ + 'For example maintainer, owner or admin.' + requires :minimum_access_level_for_delete, type: String, + values: ContainerRegistry::Protection::TagRule.minimum_access_level_for_deletes.keys, + desc: 'Minimum GitLab access level to allow to delete container tags. ' \ + 'For example maintainer, owner or admin.' + end + post do + response = + ::ContainerRegistry::Protection::CreateTagRuleService + .new(project: user_project, current_user: current_user, params: declared_params) + .execute + + render_api_error!(response.message, :unprocessable_entity) if response.error? + + present response[:container_protection_tag_rule], + with: Entities::Projects::ContainerRegistry::Protection::TagRule + end end end end diff --git a/spec/requests/api/project_container_registry_protection_tag_rules_spec.rb b/spec/requests/api/project_container_registry_protection_tag_rules_spec.rb index 1270983c61791c..15c70a57ad8f33 100644 --- a/spec/requests/api/project_container_registry_protection_tag_rules_spec.rb +++ b/spec/requests/api/project_container_registry_protection_tag_rules_spec.rb @@ -4,6 +4,8 @@ RSpec.describe API::ProjectContainerRegistryProtectionTagRules, :aggregate_failures, feature_category: :container_registry do + include ContainerRegistryHelpers + let_it_be(:project) { create(:project, :private) } let_it_be(:other_project) { create(:project, :private) } let_it_be(:tag_rule) do @@ -14,11 +16,13 @@ let_it_be(:admin) { create(:admin) } let_it_be(:api_user) { create(:user) } - let_it_be(:path) { 'registry/protection/tag/rules' } - let_it_be(:url) { "/projects/#{project.id}/#{path}" } let_it_be(:invalid_token) { 'invalid-token123' } + let_it_be(:headers_with_invalid_token) { { Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => invalid_token } } describe 'GET /projects/:id/registry/protection/tag/rules' do + let(:path) { 'registry/protection/tag/rules' } + let(:url) { "/projects/#{project.id}/#{path}" } + subject(:get_tag_rules) { get(api(url, api_user)) } shared_examples 'returning tag protection rules' do @@ -84,7 +88,119 @@ end context 'with invalid token' do - subject(:get_tag_rules) { get(api(url), headers: { 'PRIVATE-TOKEN' => invalid_token }) } + subject(:get_tag_rules) { get(api(url), headers: headers_with_invalid_token) } + + it_behaves_like 'returning response status', :unauthorized + end + end + + describe 'POST /projects/:id/registry/protection/tag/rules' do + let(:path) { 'registry/protection/tag/rules' } + let(:url) { "/projects/#{project.id}/#{path}" } + + let(:params) do + { + tag_name_pattern: 'release-*', + minimum_access_level_for_push: 'maintainer', + minimum_access_level_for_delete: 'owner' + } + end + + subject(:post_tag_rule) { post(api(url, api_user), params: params) } + + before do + stub_gitlab_api_client_to_support_gitlab_api(supported: true) + end + + shared_examples 'not creating a tag protection rule' do |status| + it "does not create a tag protection rule and returns #{status}" do + expect { post_tag_rule }.to not_change(ContainerRegistry::Protection::TagRule, :count) + + expect(response).to have_gitlab_http_status(status) + end + end + + it_behaves_like 'rejecting project protection rules request when not enough permissions' + + context 'for maintainer' do + let(:api_user) { maintainer } + + it 'creates a tag protection rule' do + expect { post_tag_rule }.to change { ContainerRegistry::Protection::TagRule.count }.by(1) + + expect(response).to have_gitlab_http_status(:created) + expect(json_response).to include( + 'tag_name_pattern' => 'release-*', + 'minimum_access_level_for_push' => 'maintainer', + 'minimum_access_level_for_delete' => 'owner' + ) + end + + context 'with missing tag_name_pattern' do + let(:params) { super().except(:tag_name_pattern) } + + it_behaves_like 'not creating a tag protection rule', :bad_request + end + + context 'with missing minimum_access_level_for_push' do + let(:params) { super().except(:minimum_access_level_for_push) } + + it_behaves_like 'not creating a tag protection rule', :bad_request + end + + context 'with missing minimum_access_level_for_delete' do + let(:params) { super().except(:minimum_access_level_for_delete) } + + it_behaves_like 'not creating a tag protection rule', :bad_request + end + + context 'with invalid minimum_access_level_for_push' do + let(:params) { super().merge(minimum_access_level_for_push: 'invalid_role') } + + it_behaves_like 'not creating a tag protection rule', :bad_request + end + + context 'with invalid minimum_access_level_for_delete' do + let(:params) { super().merge(minimum_access_level_for_delete: 'invalid_role') } + + it_behaves_like 'not creating a tag protection rule', :bad_request + end + + context 'with already existing tag_name_pattern' do + let(:params) { super().merge(tag_name_pattern: tag_rule.tag_name_pattern) } + + it_behaves_like 'not creating a tag protection rule', :unprocessable_entity + end + + context 'when the GitLab API is not supported' do + before do + stub_gitlab_api_client_to_support_gitlab_api(supported: false) + end + + it_behaves_like 'not creating a tag protection rule', :unprocessable_entity + + it 'returns error message' do + post_tag_rule + + expect(json_response).to eq({ 'message' => 'GitLab container registry API not supported' }) + end + end + + it_behaves_like 'rejecting protection rules request when invalid project' + end + + context 'for admin' do + subject(:post_tag_rule) { post(api(url, admin, admin_mode: true), params: params) } + + it 'creates a tag protection rule' do + expect { post_tag_rule }.to change { ContainerRegistry::Protection::TagRule.count }.by(1) + + expect(response).to have_gitlab_http_status(:created) + end + end + + context 'with invalid token' do + subject(:post_tag_rule) { post(api(url), headers: headers_with_invalid_token, params: params) } it_behaves_like 'returning response status', :unauthorized end -- GitLab From 9c496791141e5b4517fa77c0ce108c182922fe38 Mon Sep 17 00:00:00 2001 From: Gerardo Navarro Date: Tue, 9 Dec 2025 17:21:36 +0100 Subject: [PATCH 2/3] docs: Apply suggestions from @z_painter --- doc/api/openapi/openapi_v2.yaml | 25 +++++++++++-------- ...container_registry_protection_tag_rules.rb | 16 ++++++------ 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index 1cd2eff32a25a8..37e27ae737b71f 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -30291,7 +30291,7 @@ paths: operationId: deleteApiV4ProjectsIdRegistryProtectionRepositoryRulesProtectionRuleId "/api/v4/projects/{id}/registry/protection/tag/rules": get: - summary: Gets a list of container protection tag rules for a project + summary: Gets a list of container protection tag rules for a project. description: This feature was introduced in GitLab 18.7. produces: - application/json @@ -30303,7 +30303,7 @@ paths: required: true responses: '200': - description: Gets a list of container protection tag rules for a project + description: Gets a list of container protection tag rules for a project. schema: type: array items: @@ -30318,8 +30318,9 @@ paths: - projects operationId: getApiV4ProjectsIdRegistryProtectionTagRules post: - summary: Create a container protection tag rule for a project - description: This feature was introduced in GitLab 18.7 + summary: Create a container protection tag rule for a project. 5 rule limit + per project. + description: This feature was introduced in GitLab 18.7. produces: - application/json consumes: @@ -30337,7 +30338,8 @@ paths: "$ref": "#/definitions/postApiV4ProjectsIdRegistryProtectionTagRules" responses: '201': - description: Create a container protection tag rule for a project + description: Create a container protection tag rule for a project. 5 rule + limit per project. schema: "$ref": "#/definitions/API_Entities_Projects_ContainerRegistry_Protection_TagRule" '400': @@ -63104,19 +63106,19 @@ definitions: tag_name_pattern: type: string description: Container tag name pattern protected by the protection rule. - For example `v*-release`. Wildcard character `*` allowed. + For example, `v*-release`. Wildcard character `*` allowed. minimum_access_level_for_push: type: string - description: Minimum GitLab access level to allow to push container tags. - For example maintainer, owner or admin. + description: Minimum GitLab access level required to push container tags. + For example, Maintainer, Owner, or Admin. enum: - maintainer - owner - admin minimum_access_level_for_delete: type: string - description: Minimum GitLab access level to allow to delete container tags. - For example maintainer, owner or admin. + description: Minimum GitLab access level required to delete container tags. + For example, Maintainer, Owner, or Admin. enum: - maintainer - owner @@ -63125,7 +63127,8 @@ definitions: - tag_name_pattern - minimum_access_level_for_push - minimum_access_level_for_delete - description: Create a container protection tag rule for a project + description: Create a container protection tag rule for a project. 5 rule limit + per project. postApiV4ProjectsIdDebianDistributions: type: object properties: diff --git a/lib/api/project_container_registry_protection_tag_rules.rb b/lib/api/project_container_registry_protection_tag_rules.rb index b2938ddc6c35ca..8b4fe7a410076a 100644 --- a/lib/api/project_container_registry_protection_tag_rules.rb +++ b/lib/api/project_container_registry_protection_tag_rules.rb @@ -15,7 +15,7 @@ class ProjectContainerRegistryProtectionTagRules < ::API::Base resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do resource ':id/registry/protection/tag/rules' do - desc 'Gets a list of container protection tag rules for a project' do + desc 'Gets a list of container protection tag rules for a project.' do detail 'This feature was introduced in GitLab 18.7.' success Entities::Projects::ContainerRegistry::Protection::TagRule failure [ @@ -31,8 +31,8 @@ class ProjectContainerRegistryProtectionTagRules < ::API::Base with: Entities::Projects::ContainerRegistry::Protection::TagRule end - desc 'Create a container protection tag rule for a project' do - detail 'This feature was introduced in GitLab 18.7' + desc 'Create a container protection tag rule for a project. 5 rule limit per project.' do + detail 'This feature was introduced in GitLab 18.7.' success Entities::Projects::ContainerRegistry::Protection::TagRule failure [ { code: 400, message: 'Bad Request' }, @@ -46,15 +46,15 @@ class ProjectContainerRegistryProtectionTagRules < ::API::Base params do requires :tag_name_pattern, type: String, desc: 'Container tag name pattern protected by the protection rule. ' \ - 'For example `v*-release`. Wildcard character `*` allowed.' + 'For example, `v*-release`. Wildcard character `*` allowed.' requires :minimum_access_level_for_push, type: String, values: ContainerRegistry::Protection::TagRule.minimum_access_level_for_pushes.keys, - desc: 'Minimum GitLab access level to allow to push container tags. ' \ - 'For example maintainer, owner or admin.' + desc: 'Minimum GitLab access level required to push container tags. ' \ + 'For example, Maintainer, Owner, or Admin.' requires :minimum_access_level_for_delete, type: String, values: ContainerRegistry::Protection::TagRule.minimum_access_level_for_deletes.keys, - desc: 'Minimum GitLab access level to allow to delete container tags. ' \ - 'For example maintainer, owner or admin.' + desc: 'Minimum GitLab access level required to delete container tags. ' \ + 'For example, Maintainer, Owner, or Admin.' end post do response = -- GitLab From bcc087be9364706ad712736d67465e1334f9864e Mon Sep 17 00:00:00 2001 From: Gerardo Navarro Date: Wed, 17 Dec 2025 15:07:03 +0000 Subject: [PATCH 3/3] refactor: Apply suggestion from @adie.po --- ...iner_registry_protection_tag_rules_spec.rb | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/spec/requests/api/project_container_registry_protection_tag_rules_spec.rb b/spec/requests/api/project_container_registry_protection_tag_rules_spec.rb index 15c70a57ad8f33..7a4687d7de3348 100644 --- a/spec/requests/api/project_container_registry_protection_tag_rules_spec.rb +++ b/spec/requests/api/project_container_registry_protection_tag_rules_spec.rb @@ -112,19 +112,7 @@ stub_gitlab_api_client_to_support_gitlab_api(supported: true) end - shared_examples 'not creating a tag protection rule' do |status| - it "does not create a tag protection rule and returns #{status}" do - expect { post_tag_rule }.to not_change(ContainerRegistry::Protection::TagRule, :count) - - expect(response).to have_gitlab_http_status(status) - end - end - - it_behaves_like 'rejecting project protection rules request when not enough permissions' - - context 'for maintainer' do - let(:api_user) { maintainer } - + shared_examples 'allowed to create tag protection rule' do it 'creates a tag protection rule' do expect { post_tag_rule }.to change { ContainerRegistry::Protection::TagRule.count }.by(1) @@ -189,14 +177,26 @@ it_behaves_like 'rejecting protection rules request when invalid project' end + shared_examples 'not creating a tag protection rule' do |status| + it "does not create a tag protection rule and returns #{status}" do + expect { post_tag_rule }.to not_change(ContainerRegistry::Protection::TagRule, :count) + + expect(response).to have_gitlab_http_status(status) + end + end + + it_behaves_like 'rejecting project protection rules request when not enough permissions' + + context 'for maintainer' do + let(:api_user) { maintainer } + + it_behaves_like 'allowed to create tag protection rule' + end + context 'for admin' do subject(:post_tag_rule) { post(api(url, admin, admin_mode: true), params: params) } - it 'creates a tag protection rule' do - expect { post_tag_rule }.to change { ContainerRegistry::Protection::TagRule.count }.by(1) - - expect(response).to have_gitlab_http_status(:created) - end + it_behaves_like 'allowed to create tag protection rule' end context 'with invalid token' do -- GitLab