Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRobert Schilling <rschilling@student.tugraz.at>2018-08-25 08:38:54 +0300
committerNick Thomas <nick@gitlab.com>2018-08-25 08:38:54 +0300
commit08cc6afc6eaa05fbc072452901a7d381bbdb2af8 (patch)
treec6e4f3779ff9ff13e3539681a7ac34488aaa8d16
parent26ea5437f17ce2a6546fbfd644b93b7f18896783 (diff)
API: Protected tags
-rw-r--r--app/models/protected_tag.rb2
-rw-r--r--changelogs/unreleased/api-protected-tags.yml5
-rw-r--r--db/migrate/20180711103851_drop_duplicate_protected_tags.rb45
-rw-r--r--db/migrate/20180711103922_add_protected_tags_index.rb18
-rw-r--r--db/schema.rb1
-rw-r--r--doc/api/README.md1
-rw-r--r--doc/api/protected_tags.md128
-rw-r--r--lib/api/api.rb9
-rw-r--r--lib/api/entities.rb5
-rw-r--r--lib/api/protected_tags.rb79
-rw-r--r--spec/migrations/drop_duplicate_protected_tags_spec.rb40
-rw-r--r--spec/requests/api/protected_tags_spec.rb202
12 files changed, 531 insertions, 4 deletions
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index a36f0d36262..94746141945 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -4,6 +4,8 @@ class ProtectedTag < ActiveRecord::Base
include Gitlab::ShellAdapter
include ProtectedRef
+ validates :name, uniqueness: { scope: :project_id }
+
protected_ref_access_levels :create
def self.protected?(project, ref_name)
diff --git a/changelogs/unreleased/api-protected-tags.yml b/changelogs/unreleased/api-protected-tags.yml
new file mode 100644
index 00000000000..6e7ecf24b6e
--- /dev/null
+++ b/changelogs/unreleased/api-protected-tags.yml
@@ -0,0 +1,5 @@
+---
+title: 'API: Protected tags'
+merge_request: 14986
+author: Robert Schilling
+type: added
diff --git a/db/migrate/20180711103851_drop_duplicate_protected_tags.rb b/db/migrate/20180711103851_drop_duplicate_protected_tags.rb
new file mode 100644
index 00000000000..8fa2137551e
--- /dev/null
+++ b/db/migrate/20180711103851_drop_duplicate_protected_tags.rb
@@ -0,0 +1,45 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class DropDuplicateProtectedTags < ActiveRecord::Migration
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ BATCH_SIZE = 1000
+
+ class Project < ActiveRecord::Base
+ self.table_name = 'projects'
+
+ include ::EachBatch
+ end
+
+ class ProtectedTag < ActiveRecord::Base
+ self.table_name = 'protected_tags'
+ end
+
+ def up
+ Project.each_batch(of: BATCH_SIZE) do |projects|
+ ids = ProtectedTag
+ .where(project_id: projects)
+ .group(:name, :project_id)
+ .select('max(id)')
+
+ tags = ProtectedTag
+ .where(project_id: projects)
+ .where.not(id: ids)
+
+ if Gitlab::Database.postgresql?
+ tags.delete_all
+ else
+ # Workaround needed for MySQL
+ sql = "SELECT id FROM (#{tags.to_sql}) protected_tags"
+
+ ProtectedTag.where("id IN (#{sql})").delete_all # rubocop:disable GitlabSecurity/SqlInjection
+ end
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/migrate/20180711103922_add_protected_tags_index.rb b/db/migrate/20180711103922_add_protected_tags_index.rb
new file mode 100644
index 00000000000..7ed2258ebaf
--- /dev/null
+++ b/db/migrate/20180711103922_add_protected_tags_index.rb
@@ -0,0 +1,18 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddProtectedTagsIndex < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :protected_tags, [:project_id, :name], unique: true
+ end
+
+ def down
+ remove_concurrent_index :protected_tags, [:project_id, :name]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f5ce7df60e8..380d4e49ddf 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -1741,6 +1741,7 @@ ActiveRecord::Schema.define(version: 20180816193530) do
t.datetime "updated_at", null: false
end
+ add_index "protected_tags", ["project_id", "name"], name: "index_protected_tags_on_project_id_and_name", unique: true, using: :btree
add_index "protected_tags", ["project_id"], name: "index_protected_tags_on_project_id", using: :btree
create_table "push_event_payloads", id: false, force: :cascade do |t|
diff --git a/doc/api/README.md b/doc/api/README.md
index 45e926d3b6b..e2a6e87a2c3 100644
--- a/doc/api/README.md
+++ b/doc/api/README.md
@@ -53,6 +53,7 @@ following locations:
- [Project Members](members.md)
- [Project Snippets](project_snippets.md)
- [Protected Branches](protected_branches.md)
+- [Protected Tags](protected_tags.md)
- [Repositories](repositories.md)
- [Repository Files](repository_files.md)
- [Runners](runners.md)
diff --git a/doc/api/protected_tags.md b/doc/api/protected_tags.md
new file mode 100644
index 00000000000..aa750e467f8
--- /dev/null
+++ b/doc/api/protected_tags.md
@@ -0,0 +1,128 @@
+# Protected tags API
+
+>**Note:** This feature was introduced in GitLab 11.3
+
+**Valid access levels**
+
+Currently, these levels are recognized:
+```
+0 => No access
+30 => Developer access
+40 => Maintainer access
+```
+
+## List protected tags
+
+Gets a list of protected tags from a project.
+This function takes pagination parameters `page` and `per_page` to restrict the list of protected tags.
+
+```
+GET /projects/:id/protected_tags
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/5/protected_tags'
+```
+
+Example response:
+
+```json
+[
+ {
+ "name": "release-1-0",
+ "create_access_levels": [
+ {
+ "access_level": 40,
+ "access_level_description": "Maintainers"
+ }
+ ]
+ },
+ ...
+]
+```
+
+## Get a single protected tag or wildcard protected tag
+
+Gets a single protected tag or wildcard protected tag.
+The pagination parameters `page` and `per_page` can be used to restrict the list of protected tags.
+
+```
+GET /projects/:id/protected_tags/:name
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the tag or wildcard |
+
+```bash
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/5/protected_tags/release-1-0'
+```
+
+Example response:
+
+```json
+{
+ "name": "release-1-0",
+ "create_access_levels": [
+ {
+ "access_level": 40,
+ "access_level_description": "Maintainers"
+ }
+ ]
+}
+```
+
+## Protect repository tags
+
+Protects a single repository tag or several project repository
+tags using a wildcard protected tag.
+
+```
+POST /projects/:id/protected_tags
+```
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/5/protected_tags?name=*-stable&create_access_level=30'
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the tag or wildcard |
+| `create_access_level` | string | no | Access levels allowed to create (defaults: `40`, maintainer access level) |
+
+Example response:
+
+```json
+{
+ "name": "*-stable",
+ "create_access_levels": [
+ {
+ "access_level": 30,
+ "access_level_description": "Developers + Maintainers"
+ }
+ ]
+}
+```
+
+## Unprotect repository tags
+
+Unprotects the given protected tag or wildcard protected tag.
+
+```
+DELETE /projects/:id/protected_tags/:name
+```
+
+```bash
+curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" 'https://gitlab.example.com/api/v4/projects/5/protected_tags/*-stable'
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
+| `name` | string | yes | The name of the tag |
diff --git a/lib/api/api.rb b/lib/api/api.rb
index e2ad3c5f4e3..c000666d992 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -99,12 +99,13 @@ module API
mount ::API::Features
mount ::API::Files
mount ::API::GroupBoards
- mount ::API::Groups
mount ::API::GroupMilestones
+ mount ::API::Groups
+ mount ::API::GroupVariables
mount ::API::Internal
mount ::API::Issues
- mount ::API::Jobs
mount ::API::JobArtifacts
+ mount ::API::Jobs
mount ::API::Keys
mount ::API::Labels
mount ::API::Lint
@@ -122,11 +123,12 @@ module API
mount ::API::ProjectExport
mount ::API::ProjectImport
mount ::API::ProjectHooks
- mount ::API::Projects
mount ::API::ProjectMilestones
+ mount ::API::Projects
mount ::API::ProjectSnapshots
mount ::API::ProjectSnippets
mount ::API::ProtectedBranches
+ mount ::API::ProtectedTags
mount ::API::Repositories
mount ::API::Runner
mount ::API::Runners
@@ -143,7 +145,6 @@ module API
mount ::API::Triggers
mount ::API::Users
mount ::API::Variables
- mount ::API::GroupVariables
mount ::API::Version
mount ::API::Wikis
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 06262f0f991..95b25d7351a 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -429,6 +429,11 @@ module API
expose :merge_access_levels, using: Entities::ProtectedRefAccess
end
+ class ProtectedTag < Grape::Entity
+ expose :name
+ expose :create_access_levels, using: Entities::ProtectedRefAccess
+ end
+
class Milestone < Grape::Entity
expose :id, :iid
expose :project_id, if: -> (entity, options) { entity&.project_id }
diff --git a/lib/api/protected_tags.rb b/lib/api/protected_tags.rb
new file mode 100644
index 00000000000..bf0a7184e1c
--- /dev/null
+++ b/lib/api/protected_tags.rb
@@ -0,0 +1,79 @@
+module API
+ class ProtectedTags < Grape::API
+ include PaginationParams
+
+ TAG_ENDPOINT_REQUIREMENTS = API::PROJECT_ENDPOINT_REQUIREMENTS.merge(name: API::NO_SLASH_URL_PART_REGEX)
+
+ before { authorize_admin_project }
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
+ desc "Get a project's protected tags" do
+ detail 'This feature was introduced in GitLab 11.3.'
+ success Entities::ProtectedTag
+ end
+ params do
+ use :pagination
+ end
+ get ':id/protected_tags' do
+ protected_tags = user_project.protected_tags.preload(:create_access_levels)
+
+ present paginate(protected_tags), with: Entities::ProtectedTag, project: user_project
+ end
+
+ desc 'Get a single protected tag' do
+ detail 'This feature was introduced in GitLab 11.3.'
+ success Entities::ProtectedTag
+ end
+ params do
+ requires :name, type: String, desc: 'The name of the tag or wildcard'
+ end
+ get ':id/protected_tags/:name', requirements: TAG_ENDPOINT_REQUIREMENTS do
+ protected_tag = user_project.protected_tags.find_by!(name: params[:name])
+
+ present protected_tag, with: Entities::ProtectedTag, project: user_project
+ end
+
+ desc 'Protect a single tag or wildcard' do
+ detail 'This feature was introduced in GitLab 11.3.'
+ success Entities::ProtectedTag
+ end
+ params do
+ requires :name, type: String, desc: 'The name of the protected tag'
+ optional :create_access_level, type: Integer, default: Gitlab::Access::MAINTAINER,
+ values: ProtectedRefAccess::ALLOWED_ACCESS_LEVELS,
+ desc: 'Access levels allowed to create (defaults: `40`, maintainer access level)'
+ end
+ post ':id/protected_tags' do
+ protected_tags_params = {
+ name: params[:name],
+ create_access_levels_attributes: [{ access_level: params[:create_access_level] }]
+ }
+
+ protected_tag = ::ProtectedTags::CreateService.new(user_project,
+ current_user,
+ protected_tags_params).execute
+
+ if protected_tag.persisted?
+ present protected_tag, with: Entities::ProtectedTag, project: user_project
+ else
+ render_api_error!(protected_tag.errors.full_messages, 422)
+ end
+ end
+
+ desc 'Unprotect a single tag' do
+ detail 'This feature was introduced in GitLab 11.3.'
+ end
+ params do
+ requires :name, type: String, desc: 'The name of the protected tag'
+ end
+ delete ':id/protected_tags/:name', requirements: TAG_ENDPOINT_REQUIREMENTS do
+ protected_tag = user_project.protected_tags.find_by!(name: params[:name])
+
+ destroy_conditionally!(protected_tag)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/drop_duplicate_protected_tags_spec.rb b/spec/migrations/drop_duplicate_protected_tags_spec.rb
new file mode 100644
index 00000000000..acfb6850722
--- /dev/null
+++ b/spec/migrations/drop_duplicate_protected_tags_spec.rb
@@ -0,0 +1,40 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20180711103851_drop_duplicate_protected_tags.rb')
+
+describe DropDuplicateProtectedTags, :migration do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:protected_tags) { table(:protected_tags) }
+
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 2)
+
+ namespaces.create(id: 1, name: 'gitlab-org', path: 'gitlab-org')
+ projects.create!(id: 1, namespace_id: 1, name: 'gitlab1', path: 'gitlab1')
+ projects.create!(id: 2, namespace_id: 1, name: 'gitlab2', path: 'gitlab2')
+ end
+
+ it 'removes duplicated protected tags' do
+ protected_tags.create!(id: 1, project_id: 1, name: 'foo')
+ tag2 = protected_tags.create!(id: 2, project_id: 1, name: 'foo1')
+ protected_tags.create!(id: 3, project_id: 1, name: 'foo')
+ tag4 = protected_tags.create!(id: 4, project_id: 1, name: 'foo')
+ tag5 = protected_tags.create!(id: 5, project_id: 2, name: 'foo')
+
+ migrate!
+
+ expect(protected_tags.all.count).to eq 3
+ expect(protected_tags.all.pluck(:id)).to contain_exactly(tag2.id, tag4.id, tag5.id)
+ end
+
+ it 'does not remove unique protected tags' do
+ tag1 = protected_tags.create!(id: 1, project_id: 1, name: 'foo1')
+ tag2 = protected_tags.create!(id: 2, project_id: 1, name: 'foo2')
+ tag3 = protected_tags.create!(id: 3, project_id: 1, name: 'foo3')
+
+ migrate!
+
+ expect(protected_tags.all.count).to eq 3
+ expect(protected_tags.all.pluck(:id)).to contain_exactly(tag1.id, tag2.id, tag3.id)
+ end
+end
diff --git a/spec/requests/api/protected_tags_spec.rb b/spec/requests/api/protected_tags_spec.rb
new file mode 100644
index 00000000000..f4f3ef31bc3
--- /dev/null
+++ b/spec/requests/api/protected_tags_spec.rb
@@ -0,0 +1,202 @@
+require 'spec_helper'
+
+describe API::ProtectedTags do
+ let(:user) { create(:user) }
+ let!(:project) { create(:project, :repository) }
+ let(:project2) { create(:project, path: 'project2', namespace: user.namespace) }
+ let(:protected_name) { 'feature' }
+ let(:tag_name) { protected_name }
+ let!(:protected_tag) do
+ create(:protected_tag, project: project, name: protected_name)
+ end
+
+ describe 'GET /projects/:id/protected_tags' do
+ let(:route) { "/projects/#{project.id}/protected_tags" }
+
+ shared_examples_for 'protected tags' do
+ it 'returns the protected tags' do
+ get api(route, user), per_page: 100
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+
+ protected_tag_names = json_response.map { |x| x['name'] }
+ expected_tags_names = project.protected_tags.map { |x| x['name'] }
+ expect(protected_tag_names).to match_array(expected_tags_names)
+ end
+ end
+
+ context 'when authenticated as a maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it_behaves_like 'protected tags'
+ end
+
+ context 'when authenticated as a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, user) }
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/protected_tags/:tag' do
+ let(:route) { "/projects/#{project.id}/protected_tags/#{tag_name}" }
+
+ shared_examples_for 'protected tag' do
+ it 'returns the protected tag' do
+ get api(route, user)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response['name']).to eq(tag_name)
+ expect(json_response['create_access_levels'][0]['access_level']).to eq(::Gitlab::Access::MAINTAINER)
+ end
+
+ context 'when protected tag does not exist' do
+ let(:tag_name) { 'unknown' }
+
+ it_behaves_like '404 response' do
+ let(:request) { get api(route, user) }
+ let(:message) { '404 Not found' }
+ end
+ end
+ end
+
+ context 'when authenticated as a maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it_behaves_like 'protected tag'
+
+ context 'when protected tag contains a wildcard' do
+ let(:protected_name) { 'feature*' }
+
+ it_behaves_like 'protected tag'
+ end
+ end
+
+ context 'when authenticated as a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it_behaves_like '403 response' do
+ let(:request) { get api(route, user) }
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/protected_tags' do
+ let(:tag_name) { 'new_tag' }
+
+ context 'when authenticated as a maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'protects a single tag with maintainers can create tags' do
+ post api("/projects/#{project.id}/protected_tags", user), name: tag_name
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(tag_name)
+ expect(json_response['create_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
+ end
+
+ it 'protects a single tag with developers can create tags' do
+ post api("/projects/#{project.id}/protected_tags", user),
+ name: tag_name, create_access_level: 30
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(tag_name)
+ expect(json_response['create_access_levels'][0]['access_level']).to eq(Gitlab::Access::DEVELOPER)
+ end
+
+ it 'protects a single tag with no one can create tags' do
+ post api("/projects/#{project.id}/protected_tags", user),
+ name: tag_name, create_access_level: 0
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(tag_name)
+ expect(json_response['create_access_levels'][0]['access_level']).to eq(Gitlab::Access::NO_ACCESS)
+ end
+
+ it 'returns a 422 error if the same tag is protected twice' do
+ post api("/projects/#{project.id}/protected_tags", user), name: protected_name
+
+ expect(response).to have_gitlab_http_status(422)
+ expect(json_response['message'][0]).to eq('Name has already been taken')
+ end
+
+ it 'returns 201 if the same tag is proteted on different projects' do
+ post api("/projects/#{project.id}/protected_tags", user), name: protected_name
+ post api("/projects/#{project2.id}/protected_tags", user), name: protected_name
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(protected_name)
+ end
+
+ context 'when tag has a wildcard in its name' do
+ let(:tag_name) { 'feature/*' }
+
+ it 'protects multiple tags with a wildcard in the name' do
+ post api("/projects/#{project.id}/protected_tags", user), name: tag_name
+
+ expect(response).to have_gitlab_http_status(201)
+ expect(json_response['name']).to eq(tag_name)
+ expect(json_response['create_access_levels'][0]['access_level']).to eq(Gitlab::Access::MAINTAINER)
+ end
+ end
+ end
+
+ context 'when authenticated as a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ it 'returns a 403 error if guest' do
+ post api("/projects/#{project.id}/protected_tags/", user), name: tag_name
+
+ expect(response).to have_gitlab_http_status(403)
+ end
+ end
+ end
+
+ describe 'DELETE /projects/:id/protected_tags/unprotect/:tag' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'unprotects a single tag' do
+ delete api("/projects/#{project.id}/protected_tags/#{tag_name}", user)
+
+ expect(response).to have_gitlab_http_status(204)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) { api("/projects/#{project.id}/protected_tags/#{tag_name}", user) }
+ end
+
+ it "returns 404 if tag does not exist" do
+ delete api("/projects/#{project.id}/protected_tags/barfoo", user)
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+
+ context 'when tag has a wildcard in its name' do
+ let(:protected_name) { 'feature*' }
+
+ it 'unprotects a wildcard tag' do
+ delete api("/projects/#{project.id}/protected_tags/#{tag_name}", user)
+
+ expect(response).to have_gitlab_http_status(204)
+ end
+ end
+ end
+end