Commit 08cc6afc authored by Robert Schilling's avatar Robert Schilling Committed by Nick Thomas

API: Protected tags

parent 26ea5437
...@@ -4,6 +4,8 @@ class ProtectedTag < ActiveRecord::Base ...@@ -4,6 +4,8 @@ class ProtectedTag < ActiveRecord::Base
include Gitlab::ShellAdapter include Gitlab::ShellAdapter
include ProtectedRef include ProtectedRef
validates :name, uniqueness: { scope: :project_id }
protected_ref_access_levels :create protected_ref_access_levels :create
def self.protected?(project, ref_name) def self.protected?(project, ref_name)
......
---
title: 'API: Protected tags'
merge_request: 14986
author: Robert Schilling
type: added
# 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
# 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
...@@ -1741,6 +1741,7 @@ ActiveRecord::Schema.define(version: 20180816193530) do ...@@ -1741,6 +1741,7 @@ ActiveRecord::Schema.define(version: 20180816193530) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end 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 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| create_table "push_event_payloads", id: false, force: :cascade do |t|
......
...@@ -53,6 +53,7 @@ following locations: ...@@ -53,6 +53,7 @@ following locations:
- [Project Members](members.md) - [Project Members](members.md)
- [Project Snippets](project_snippets.md) - [Project Snippets](project_snippets.md)
- [Protected Branches](protected_branches.md) - [Protected Branches](protected_branches.md)
- [Protected Tags](protected_tags.md)
- [Repositories](repositories.md) - [Repositories](repositories.md)
- [Repository Files](repository_files.md) - [Repository Files](repository_files.md)
- [Runners](runners.md) - [Runners](runners.md)
......
# 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 |
...@@ -99,12 +99,13 @@ module API ...@@ -99,12 +99,13 @@ module API
mount ::API::Features mount ::API::Features
mount ::API::Files mount ::API::Files
mount ::API::GroupBoards mount ::API::GroupBoards
mount ::API::Groups
mount ::API::GroupMilestones mount ::API::GroupMilestones
mount ::API::Groups
mount ::API::GroupVariables
mount ::API::Internal mount ::API::Internal
mount ::API::Issues mount ::API::Issues
mount ::API::Jobs
mount ::API::JobArtifacts mount ::API::JobArtifacts
mount ::API::Jobs
mount ::API::Keys mount ::API::Keys
mount ::API::Labels mount ::API::Labels
mount ::API::Lint mount ::API::Lint
...@@ -122,11 +123,12 @@ module API ...@@ -122,11 +123,12 @@ module API
mount ::API::ProjectExport mount ::API::ProjectExport
mount ::API::ProjectImport mount ::API::ProjectImport
mount ::API::ProjectHooks mount ::API::ProjectHooks
mount ::API::Projects
mount ::API::ProjectMilestones mount ::API::ProjectMilestones
mount ::API::Projects
mount ::API::ProjectSnapshots mount ::API::ProjectSnapshots
mount ::API::ProjectSnippets mount ::API::ProjectSnippets
mount ::API::ProtectedBranches mount ::API::ProtectedBranches
mount ::API::ProtectedTags
mount ::API::Repositories mount ::API::Repositories
mount ::API::Runner mount ::API::Runner
mount ::API::Runners mount ::API::Runners
...@@ -143,7 +145,6 @@ module API ...@@ -143,7 +145,6 @@ module API
mount ::API::Triggers mount ::API::Triggers
mount ::API::Users mount ::API::Users
mount ::API::Variables mount ::API::Variables
mount ::API::GroupVariables
mount ::API::Version mount ::API::Version
mount ::API::Wikis mount ::API::Wikis
......
...@@ -429,6 +429,11 @@ module API ...@@ -429,6 +429,11 @@ module API
expose :merge_access_levels, using: Entities::ProtectedRefAccess expose :merge_access_levels, using: Entities::ProtectedRefAccess
end end
class ProtectedTag < Grape::Entity
expose :name
expose :create_access_levels, using: Entities::ProtectedRefAccess
end
class Milestone < Grape::Entity class Milestone < Grape::Entity
expose :id, :iid expose :id, :iid
expose :project_id, if: -> (entity, options) { entity&.project_id } expose :project_id, if: -> (entity, options) { entity&.project_id }
......
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
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
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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment