Commit 58cebb97 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '3264-project-aliases' into 'master'

Support remapping of Git repos via SSH with project aliases

See merge request gitlab-org/gitlab-ee!14108
parents 35b22b03 c5ed781e
...@@ -19,3 +19,5 @@ db/ @abrandl @NikolayS ...@@ -19,3 +19,5 @@ db/ @abrandl @NikolayS
/lib/gitlab/ci/templates/ @nolith @zj /lib/gitlab/ci/templates/ @nolith @zj
/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml @DylanGriffith @mayra-cabrera @tkuah /lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml @DylanGriffith @mayra-cabrera @tkuah
/lib/gitlab/ci/templates/Security/ @plafoucriere @gonzoyumo @twoodham /lib/gitlab/ci/templates/Security/ @plafoucriere @gonzoyumo @twoodham
/ee/app/models/project_alias.rb @patrickbajao
/ee/lib/api/project_aliases.rb @patrickbajao
# frozen_string_literal: true
class CreateProjectAliases < ActiveRecord::Migration[5.1]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :project_aliases do |t|
t.references :project, null: false, index: true, foreign_key: { on_delete: :cascade }, type: :integer
t.string :name, null: false, index: { unique: true }
t.timestamps_with_timezone null: false
end
end
end
...@@ -2412,6 +2412,15 @@ ActiveRecord::Schema.define(version: 20190625184066) do ...@@ -2412,6 +2412,15 @@ ActiveRecord::Schema.define(version: 20190625184066) do
t.string "encrypted_token_iv", null: false t.string "encrypted_token_iv", null: false
end end
create_table "project_aliases", force: :cascade do |t|
t.integer "project_id", null: false
t.string "name", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.index ["name"], name: "index_project_aliases_on_name", unique: true, using: :btree
t.index ["project_id"], name: "index_project_aliases_on_project_id", using: :btree
end
create_table "project_authorizations", id: false, force: :cascade do |t| create_table "project_authorizations", id: false, force: :cascade do |t|
t.integer "user_id", null: false t.integer "user_id", null: false
t.integer "project_id", null: false t.integer "project_id", null: false
...@@ -3793,6 +3802,7 @@ ActiveRecord::Schema.define(version: 20190625184066) do ...@@ -3793,6 +3802,7 @@ ActiveRecord::Schema.define(version: 20190625184066) do
add_foreign_key "pool_repositories", "projects", column: "source_project_id", on_delete: :nullify add_foreign_key "pool_repositories", "projects", column: "source_project_id", on_delete: :nullify
add_foreign_key "pool_repositories", "shards", on_delete: :restrict add_foreign_key "pool_repositories", "shards", on_delete: :restrict
add_foreign_key "project_alerting_settings", "projects", on_delete: :cascade add_foreign_key "project_alerting_settings", "projects", on_delete: :cascade
add_foreign_key "project_aliases", "projects", on_delete: :cascade
add_foreign_key "project_authorizations", "projects", on_delete: :cascade add_foreign_key "project_authorizations", "projects", on_delete: :cascade
add_foreign_key "project_authorizations", "users", on_delete: :cascade add_foreign_key "project_authorizations", "users", on_delete: :cascade
add_foreign_key "project_auto_devops", "projects", on_delete: :cascade add_foreign_key "project_auto_devops", "projects", on_delete: :cascade
......
# Project Aliases API **[PREMIUM ONLY]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/3264) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.1.
All methods require administrator authorization.
## List all project aliases
Get a list of all project aliases:
```
GET /project_aliases
```
```
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/project_aliases"
```
Example response:
```json
[
{
"id": 1,
"project_id": 1,
"name": "gitlab-ce"
},
{
"id": 2,
"project_id": 2,
"name": "gitlab-ee"
}
]
```
## Get project alias' details
Get details of a project alias:
```
GET /project_aliases/:name
```
| Attribute | Type | Required | Description |
|-----------|--------|----------|-----------------------|
| `name` | string | yes | The name of the alias |
```
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/project_aliases/gitlab-ee"
```
Example response:
```json
{
"id": 1,
"project_id": 1,
"name": "gitlab-ee"
}
```
## Create a project alias
Add a new alias for a project. Responds with a 201 when successful,
400 when there are validation errors (e.g. alias already exists):
```
POST /project_aliases
```
| Attribute | Type | Required | Description |
|--------------|--------|----------|-----------------------------------------------|
| `project_id` | string | yes | The ID or URL-encoded path of the project. |
| `name` | string | yes | The name of the alias. Must be unique. |
```
curl --request POST "https://gitlab.example.com/api/v4/project_aliases" --form "project_id=gitlab-org%2Fgitlab-ee" --form "name=gitlab-ee"
```
Example response:
```json
{
"id": 1,
"project_id": 1,
"name": "gitlab-ee"
}
```
## Delete a project alias
Removes a project aliases. Responds with a 204 when project alias
exists, 404 when it doesn't:
```
DELETE /project_aliases/:name
```
| Attribute | Type | Required | Description |
|-----------|--------|----------|-----------------------|
| `name` | string | yes | The name of the alias |
```
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/project_aliases/gitlab-ee"
```
...@@ -365,3 +365,8 @@ for details about the pipelines security model. ...@@ -365,3 +365,8 @@ for details about the pipelines security model.
Since GitLab 8.15, LDAP user permissions can now be manually overridden by an admin user. Since GitLab 8.15, LDAP user permissions can now be manually overridden by an admin user.
Read through the documentation on [LDAP users permissions](../administration/auth/how_to_configure_ldap_gitlab_ee/index.html) to learn more. Read through the documentation on [LDAP users permissions](../administration/auth/how_to_configure_ldap_gitlab_ee/index.html) to learn more.
## Project aliases
Project aliases can only be read, created and deleted by a GitLab administrator.
Read through the documentation on [Project aliases](../user/project/index.md#project-aliases-premium-only) to learn more.
...@@ -193,6 +193,28 @@ password <personal_access_token> ...@@ -193,6 +193,28 @@ password <personal_access_token>
To quickly access a project from the GitLab UI using the project ID, To quickly access a project from the GitLab UI using the project ID,
visit the `/projects/:id` URL in your browser or other tool accessing the project. visit the `/projects/:id` URL in your browser or other tool accessing the project.
## Project aliases **[PREMIUM ONLY]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/3264) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.1.
When migrating repositories to GitLab and they are being accessed by other systems,
it's very useful to be able to access them using the same name especially when
they are a lot. It reduces the risk of changing significant number of Git URLs in
a large number of systems.
GitLab provides a functionality to help with this. In GitLab, repositories are
usually accessed with a namespace and project name. It is also possible to access
them via a project alias. This feature is only available on Git over SSH.
A project alias can be only created via API and only by GitLab administrators.
Follow the [Project Aliases API documentation](../../api/project_aliases.md) for
more details.
Once an alias has been created for a project (e.g., an alias `gitlab-ce` for the
project `https://gitlab.com/gitlab-org/gitlab-ce`), the repository can be cloned
using the alias (e.g `git clone git@gitlab.com:gitlab-ce.git` instead of
`git clone git@gitlab.com:gitlab-org/gitlab-ce.git`).
## Project APIs ## Project APIs
There are numerous [APIs](../../api/README.md) to use with your projects: There are numerous [APIs](../../api/README.md) to use with your projects:
...@@ -212,3 +234,4 @@ There are numerous [APIs](../../api/README.md) to use with your projects: ...@@ -212,3 +234,4 @@ There are numerous [APIs](../../api/README.md) to use with your projects:
- [Templates](../../api/project_templates.md) - [Templates](../../api/project_templates.md)
- [Traffic](../../api/project_statistics.md) - [Traffic](../../api/project_statistics.md)
- [Variables](../../api/project_level_variables.md) - [Variables](../../api/project_level_variables.md)
- [Aliases](../../api/project_aliases.md)
...@@ -76,6 +76,8 @@ module EE ...@@ -76,6 +76,8 @@ module EE
has_many :operations_feature_flags, class_name: 'Operations::FeatureFlag' has_many :operations_feature_flags, class_name: 'Operations::FeatureFlag'
has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient' has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient'
has_many :project_aliases
scope :with_shared_runners_limit_enabled, -> { with_shared_runners.non_public_only } scope :with_shared_runners_limit_enabled, -> { with_shared_runners.non_public_only }
scope :mirror, -> { where(mirror: true) } scope :mirror, -> { where(mirror: true) }
......
...@@ -86,6 +86,7 @@ class License < ApplicationRecord ...@@ -86,6 +86,7 @@ class License < ApplicationRecord
metrics_reports metrics_reports
custom_prometheus_metrics custom_prometheus_metrics
required_ci_templates required_ci_templates
project_aliases
] ]
EEP_FEATURES.freeze EEP_FEATURES.freeze
...@@ -191,6 +192,7 @@ class License < ApplicationRecord ...@@ -191,6 +192,7 @@ class License < ApplicationRecord
custom_project_templates custom_project_templates
usage_quotas usage_quotas
required_ci_templates required_ci_templates
project_aliases
].freeze ].freeze
validate :valid_license validate :valid_license
......
# frozen_string_literal: true
class ProjectAlias < ApplicationRecord
belongs_to :project
validates :project, presence: true
validates :name,
presence: true,
uniqueness: true,
format: {
with: ::Gitlab::PathRegex.project_path_format_regex,
message: ::Gitlab::PathRegex.project_path_format_message
}
end
---
title: Support remapping of Git repos via SSH with project aliases
merge_request: 14108
author:
type: added
# frozen_string_literal: true
module API
class ProjectAliases < Grape::API
include PaginationParams
before { check_feature_availability }
before { authenticated_as_admin! }
helpers do
def project_alias
ProjectAlias.find_by_name!(params[:name])
end
def project
find_project!(params[:project_id])
end
def check_feature_availability
forbidden! unless ::License.feature_available?(:project_aliases)
end
end
resource :project_aliases do
desc 'Get a list of all project aliases' do
success EE::API::Entities::ProjectAlias
end
params do
use :pagination
end
get do
present paginate(ProjectAlias.all), with: EE::API::Entities::ProjectAlias
end
desc 'Get info of specific project alias by name' do
success EE::API::Entities::ProjectAlias
end
get ':name' do
present project_alias, with: EE::API::Entities::ProjectAlias
end
desc 'Create a project alias'
params do
requires :project_id, type: String, desc: 'The ID or URL-encoded path of the project'
requires :name, type: String, desc: 'The alias of the project'
end
post do
project_alias = project.project_aliases.create(name: params[:name])
if project_alias.valid?
present project_alias, with: EE::API::Entities::ProjectAlias
else
render_validation_error!(project_alias)
end
end
desc 'Delete a project alias by name'
delete ':name' do
project_alias.destroy
status 204
end
end
end
end
...@@ -31,6 +31,7 @@ module EE ...@@ -31,6 +31,7 @@ module EE
mount ::API::ProjectApprovals mount ::API::ProjectApprovals
mount ::API::Vulnerabilities mount ::API::Vulnerabilities
mount ::API::MergeRequestApprovals mount ::API::MergeRequestApprovals
mount ::API::ProjectAliases
version 'v3', using: :path do version 'v3', using: :path do
# Although the following endpoints are kept behind V3 namespace, # Although the following endpoints are kept behind V3 namespace,
......
...@@ -729,6 +729,10 @@ module EE ...@@ -729,6 +729,10 @@ module EE
class ManagedLicense < Grape::Entity class ManagedLicense < Grape::Entity
expose :id, :name, :approval_status expose :id, :name, :approval_status
end end
class ProjectAlias < Grape::Entity
expose :id, :project_id, :name
end
end end
end end
end end
# frozen_string_literal: true
module EE
module Gitlab
module RepoPath
module ClassMethods
def find_project(project_path)
return super unless License.feature_available?(:project_aliases)
if project_alias = ProjectAlias.find_by_name(project_path)
[project_alias.project, false]
else
super
end
end
end
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :project_alias do
project
name { FFaker::Name.unique.name.parameterize }
end
end
{
"type": "object",
"allOf": [
{
"required" : [
"id",
"project_id",
"name"
],
"properties": {
"id": { "type": "integer" },
"project_id": { "type": "integer" },
"name": { "type": "string" }
}
}
]
}
{
"type": "array",
"items": { "$ref": "./project_alias.json" }
}
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::RepoPath do
describe '.find_project' do
let(:project) { create(:project) }
context 'without premium license' do
context 'project_path matches a project alias' do
let(:project_alias) { create(:project_alias, project: project) }
it 'does not return a project' do
expect(described_class.find_project(project_alias.name)).to eq([nil, nil])
end
end
end
context 'with premium license' do
before do
stub_licensed_features(project_aliases: true)
end
context 'project_path matches a project alias' do
let(:project_alias) { create(:project_alias, project: project) }
it 'returns the project' do
expect(described_class.find_project(project_alias.name)).to eq([project, false])
end
end
context 'project_path does not match a project alias' do
context 'project path matches project full path' do
it 'returns the project' do
expect(described_class.find_project(project.full_path)).to eq([project, false])
end
end
context 'project path does not match an existing project full path' do
it 'returns nil' do
expect(described_class.find_project('some-project')).to eq([nil, nil])
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ProjectAlias do
subject { build(:project_alias) }
it { is_expected.to belong_to(:project) }
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name) }
it { is_expected.not_to allow_value('/foo').for(:name) }
it { is_expected.not_to allow_value('foo/foo').for(:name) }
it { is_expected.not_to allow_value('foo.git').for(:name) }
end
...@@ -34,6 +34,7 @@ describe Project do ...@@ -34,6 +34,7 @@ describe Project do
it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') } it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') }
it { is_expected.to have_one(:github_service) } it { is_expected.to have_one(:github_service) }
it { is_expected.to have_many(:project_aliases) }
end end
context 'scopes' do context 'scopes' do
......
...@@ -3,11 +3,12 @@ require 'spec_helper' ...@@ -3,11 +3,12 @@ require 'spec_helper'
describe API::Internal do describe API::Internal do
describe "POST /internal/allowed" do describe "POST /internal/allowed" do
set(:user) { create(:user) }
set(:key) { create(:key, user: user) }
let(:secret_token) { Gitlab::Shell.secret_token }
context "for design repositories" do context "for design repositories" do
set(:user) { create(:user) }
set(:project) { create(:project) } set(:project) { create(:project) }
set(:key) { create(:key, user: user) }
let(:secret_token) { Gitlab::Shell.secret_token }
let(:gl_repository) { EE::Gitlab::GlRepository::DESIGN.identifier_for_subject(project) } let(:gl_repository) { EE::Gitlab::GlRepository::DESIGN.identifier_for_subject(project) }
it "does not allow access" do it "does not allow access" do
...@@ -23,5 +24,61 @@ describe API::Internal do ...@@ -23,5 +24,61 @@ describe API::Internal do
expect(response).to have_gitlab_http_status(401) expect(response).to have_gitlab_http_status(401)
end end
end end
context "project alias" do
let(:project) { create(:project, :public, :repository) }
let(:project_alias) { create(:project_alias, project: project) }
def check_access_by_alias(alias_name)
post(
api("/internal/allowed"),
params: {
action: "git-upload-pack",
key_id: key.id,
project: alias_name,
protocol: 'ssh',
secret_token: secret_token
}
)
end
context "without premium license" do
context "project matches a project alias" do
before do
check_access_by_alias(project_alias.name)
end
it "does not allow access because project can't be found" do
expect(response).to have_gitlab_http_status(404)
end
end
end
context "with premium license" do
before do
stub_licensed_features(project_aliases: true)
end
context "project matches a project alias" do
before do
check_access_by_alias(project_alias.name)
end
it "allows access" do
expect(response).to have_gitlab_http_status(200)
end
end
context "project doesn't match a project alias" do
before do
check_access_by_alias('some-project')
end
it "does not allow access because project can't be found" do
expect(response).to have_gitlab_http_status(404)
end
end
end
end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
describe API::ProjectAliases, api: true do
let(:user) { create(:user) }
let(:admin) { create(:admin) }
context 'without premium license' do
describe 'GET /project_aliases' do
before do
get api('/project_aliases', admin)
end
it 'returns 403' do
expect(response).to have_gitlab_http_status(403)
end
end
describe 'GET /project_aliases/:name' do
let(:project_alias) { create(:project_alias) }
before do
get api("/project_aliases/#{project_alias.name}", admin)
end
it 'returns 403' do
expect(response).to have_gitlab_http_status(403)
end
end
describe 'POST /project_aliases' do
let(:project) { create(:project) }
before do
post api("/project_aliases", admin), params: { project_id: project.id, name: 'some-project' }
end
it 'returns 403' do
expect(response).to have_gitlab_http_status(403)
end
end
describe 'DELETE /project_aliases/:name' do
let(:project_alias) { create(:project_alias) }
before do
delete api("/project_aliases/#{project_alias.name}", admin)
end
it 'returns 403' do
expect(response).to have_gitlab_http_status(403)
end
end
end
context 'with premium license' do
shared_examples_for 'GitLab administrator only API endpoint' do
context 'anonymous user' do
let(:user) { nil }
it 'returns 401' do
expect(response).to have_gitlab_http_status(401)
end
end
context 'regular user' do
it 'returns 403' do
expect(response).to have_gitlab_http_status(403)
end
end
end
before do
stub_licensed_features(project_aliases: true)
end
describe 'GET /project_aliases' do
before do
get api('/project_aliases', user)
end
it_behaves_like 'GitLab administrator only API endpoint'
context 'admin' do
let(:user) { admin }
let!(:project_alias_1) { create(:project_alias) }
let!(:project_alias_2) { create(:project_alias) }
it 'returns the project aliases list' do
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/project_aliases', dir: 'ee')
expect(response).to include_pagination_headers
end
end
end
describe 'GET /project_aliases/:name' do
let(:project_alias) { create(:project_alias) }
let(:alias_name) { project_alias.name }
before do
get api("/project_aliases/#{alias_name}", user)
end
it_behaves_like 'GitLab administrator only API endpoint'
context 'admin' do
let(:user) { admin }
context 'existing project alias' do
it 'returns the project alias' do
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('public_api/v4/project_alias', dir: 'ee')
end
end
context 'non-existent project alias' do
let(:alias_name) { 'some-project' }
it 'returns 404' do
expect(response).to have_gitlab_http_status(404)
end
end
end
end
describe 'POST /project_aliases' do
let(:project) { create(:project) }
let(:project_alias) { create(:project_alias) }
let(:alias_name) { project_alias.name }
before do
post api("/project_aliases", user), params: { project_id: project.id, name: alias_name }
end
it_behaves_like 'GitLab administrator only API endpoint'
context 'admin' do
let(:user) { admin }
context 'existing project alias' do
it 'returns 400' do
expect(response).to have_gitlab_http_status(400)
end
end
context 'non-existent project alias' do
let(:alias_name) { 'some-project' }
it 'returns 200' do
expect(response).to have_gitlab_http_status(201)
expect(response).to match_response_schema('public_api/v4/project_alias', dir: 'ee')
end
end
end
end
describe 'DELETE /project_aliases/:name' do
let(:project_alias) { create(:project_alias) }
let(:alias_name) { project_alias.name }
before do
delete api("/project_aliases/#{alias_name}", user)
end
it_behaves_like 'GitLab administrator only API endpoint'
context 'admin' do
let(:user) { admin }
context 'existing project alias' do
it 'returns 204' do
expect(response).to have_gitlab_http_status(204)
end
end
context 'non-existent project alias' do
let(:alias_name) { 'some-project' }
it 'returns 404' do
expect(response).to have_gitlab_http_status(404)
end
end
end
end
end
end
...@@ -38,3 +38,5 @@ module Gitlab ...@@ -38,3 +38,5 @@ module Gitlab
end end
end end
end end
Gitlab::RepoPath.singleton_class.prepend(EE::Gitlab::RepoPath::ClassMethods)
...@@ -396,6 +396,7 @@ project: ...@@ -396,6 +396,7 @@ project:
- incident_management_setting - incident_management_setting
- merge_trains - merge_trains
- designs - designs
- project_aliases
award_emoji: award_emoji:
- awardable - awardable
- user - user
......
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