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
/lib/gitlab/ci/templates/ @nolith @zj
/lib/gitlab/ci/templates/Auto-DevOps.gitlab-ci.yml @DylanGriffith @mayra-cabrera @tkuah
/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
t.string "encrypted_token_iv", null: false
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|
t.integer "user_id", null: false
t.integer "project_id", null: false
......@@ -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", "shards", on_delete: :restrict
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", "users", 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.
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.
## 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>
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.
## 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
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)
- [Traffic](../../api/project_statistics.md)
- [Variables](../../api/project_level_variables.md)
- [Aliases](../../api/project_aliases.md)
......@@ -76,6 +76,8 @@ module EE
has_many :operations_feature_flags, class_name: 'Operations::FeatureFlag'
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 :mirror, -> { where(mirror: true) }
......
......@@ -86,6 +86,7 @@ class License < ApplicationRecord
metrics_reports
custom_prometheus_metrics
required_ci_templates
project_aliases
]
EEP_FEATURES.freeze
......@@ -191,6 +192,7 @@ class License < ApplicationRecord
custom_project_templates
usage_quotas
required_ci_templates
project_aliases
].freeze
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
mount ::API::ProjectApprovals
mount ::API::Vulnerabilities
mount ::API::MergeRequestApprovals
mount ::API::ProjectAliases
version 'v3', using: :path do
# Although the following endpoints are kept behind V3 namespace,
......
......@@ -729,6 +729,10 @@ module EE
class ManagedLicense < Grape::Entity
expose :id, :name, :approval_status
end
class ProjectAlias < Grape::Entity
expose :id, :project_id, :name
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
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_many(:project_aliases) }
end
context 'scopes' do
......
......@@ -3,11 +3,12 @@ require 'spec_helper'
describe API::Internal 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
set(:user) { create(:user) }
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) }
it "does not allow access" do
......@@ -23,5 +24,61 @@ describe API::Internal do
expect(response).to have_gitlab_http_status(401)
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
# 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
end
end
end
Gitlab::RepoPath.singleton_class.prepend(EE::Gitlab::RepoPath::ClassMethods)
......@@ -396,6 +396,7 @@ project:
- incident_management_setting
- merge_trains
- designs
- project_aliases
award_emoji:
- awardable
- 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