Commit afb033af authored by Ash McKenzie's avatar Ash McKenzie

Merge branch '235490-generic-packages/upload' into 'master'

Upload packages to Generic package registry

See merge request gitlab-org/gitlab!40690
parents be56d55c 1e684a3e
...@@ -10,6 +10,7 @@ module Packages ...@@ -10,6 +10,7 @@ module Packages
.with_package_type(package_type) .with_package_type(package_type)
.safe_find_or_create_by!(name: name, version: version) do |pkg| .safe_find_or_create_by!(name: name, version: version) do |pkg|
pkg.creator = package_creator pkg.creator = package_creator
yield pkg if block_given?
end end
end end
......
# frozen_string_literal: true
module Packages
module Generic
class CreatePackageFileService < BaseService
def execute
::Packages::Package.transaction do
create_package_file(find_or_create_package)
end
end
private
def find_or_create_package
package_params = {
name: params[:package_name],
version: params[:package_version],
build: params[:build]
}
::Packages::Generic::FindOrCreatePackageService
.new(project, current_user, package_params)
.execute
end
def create_package_file(package)
file_params = {
file: params[:file],
size: params[:file].size,
file_sha256: params[:file].sha256,
file_name: params[:file_name]
}
::Packages::CreatePackageFileService.new(package, file_params).execute
end
end
end
end
# frozen_string_literal: true
module Packages
module Generic
class FindOrCreatePackageService < ::Packages::CreatePackageService
def execute
find_or_create_package!(::Packages::Package.package_types['generic']) do |package|
if params[:build].present?
package.build_info = Packages::BuildInfo.new(pipeline: params[:build].pipeline)
end
end
end
end
end
end
...@@ -2,6 +2,11 @@ ...@@ -2,6 +2,11 @@
module API module API
class GenericPackages < Grape::API::Instance class GenericPackages < Grape::API::Instance
GENERIC_PACKAGES_REQUIREMENTS = {
package_name: API::NO_SLASH_URL_PART_REGEX,
file_name: API::NO_SLASH_URL_PART_REGEX
}.freeze
before do before do
require_packages_enabled! require_packages_enabled!
authenticate! authenticate!
...@@ -17,17 +22,71 @@ module API ...@@ -17,17 +22,71 @@ module API
route_setting :authentication, job_token_allowed: true route_setting :authentication, job_token_allowed: true
namespace ':id/packages/generic' do namespace ':id/packages/generic' do
get 'ping' do namespace ':package_name/*package_version/:file_name', requirements: GENERIC_PACKAGES_REQUIREMENTS do
:pong desc 'Workhorse authorize generic package file' do
detail 'This feature was introduced in GitLab 13.5'
end
route_setting :authentication, job_token_allowed: true
params do
requires :package_name, type: String, desc: 'Package name'
requires :package_version, type: String, desc: 'Package version', regexp: Gitlab::Regex.generic_package_version_regex
requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.generic_package_file_name_regex, file_path: true
end
put 'authorize' do
authorize_workhorse!(subject: project, maximum_size: project.actual_limits.generic_packages_max_file_size)
end
desc 'Upload package file' do
detail 'This feature was introduced in GitLab 13.5'
end
params do
requires :package_name, type: String, desc: 'Package name'
requires :package_version, type: String, desc: 'Package version', regexp: Gitlab::Regex.generic_package_version_regex
requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.generic_package_file_name_regex, file_path: true
requires :file, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)'
end
route_setting :authentication, job_token_allowed: true
put do
authorize_upload!(project)
bad_request!('File is too large') if max_file_size_exceeded?
track_event('push_package')
create_package_file_params = declared_params.merge(build: current_authenticated_job)
::Packages::Generic::CreatePackageFileService
.new(project, current_user, create_package_file_params)
.execute
created!
rescue ObjectStorage::RemoteStoreError => e
Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:file_name], project_id: project.id })
forbidden!
end
end end
end end
end end
helpers do helpers do
include ::API::Helpers::PackagesHelpers include ::API::Helpers::PackagesHelpers
include ::API::Helpers::Packages::BasicAuthHelpers
def require_generic_packages_available! def require_generic_packages_available!
not_found! unless Feature.enabled?(:generic_packages, user_project) not_found! unless Feature.enabled?(:generic_packages, project)
end
def project
authorized_user_project
end
def max_file_size_exceeded?
project.actual_limits.exceeded?(:generic_packages_max_file_size, params[:file].size)
end end
end end
end end
......
...@@ -103,6 +103,10 @@ module Gitlab ...@@ -103,6 +103,10 @@ module Gitlab
def generic_package_version_regex def generic_package_version_regex
/\A\d+\.\d+\.\d+\z/ /\A\d+\.\d+\.\d+\z/
end end
def generic_package_file_name_regex
maven_file_name_regex
end
end end
extend self extend self
......
...@@ -434,4 +434,18 @@ RSpec.describe Gitlab::Regex do ...@@ -434,4 +434,18 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('%2e%2e%2f1.2.3') } it { is_expected.not_to match('%2e%2e%2f1.2.3') }
it { is_expected.not_to match('') } it { is_expected.not_to match('') }
end end
describe '.generic_package_file_name_regex' do
subject { described_class.generic_package_file_name_regex }
it { is_expected.to match('123') }
it { is_expected.to match('foo') }
it { is_expected.to match('foo.bar.baz-2.0-20190901.47283-1.jar') }
it { is_expected.not_to match('../../foo') }
it { is_expected.not_to match('..\..\foo') }
it { is_expected.not_to match('%2f%2e%2e%2f%2essh%2fauthorized_keys') }
it { is_expected.not_to match('$foo/bar') }
it { is_expected.not_to match('my file name') }
it { is_expected.not_to match('!!()()') }
end
end end
...@@ -4,79 +4,268 @@ require 'spec_helper' ...@@ -4,79 +4,268 @@ require 'spec_helper'
RSpec.describe API::GenericPackages do RSpec.describe API::GenericPackages do
let_it_be(:personal_access_token) { create(:personal_access_token) } let_it_be(:personal_access_token) { create(:personal_access_token) }
let_it_be(:project) { create(:project) } let_it_be(:project, reload: true) { create(:project) }
let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') }
describe 'GET /api/v4/projects/:id/packages/generic/ping' do let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } }
let(:user) { personal_access_token.user } let(:user) { personal_access_token.user }
let(:auth_token) { personal_access_token.token } let(:ci_build) { create(:ci_build, :running, user: user) }
def auth_header
return {} if user_role == :anonymous
case authenticate_with
when :personal_access_token
personal_access_token_header
when :job_token
job_token_header
when :invalid_personal_access_token
personal_access_token_header('wrong token')
when :invalid_job_token
job_token_header('wrong token')
end
end
def personal_access_token_header(value = nil)
{ Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => value || personal_access_token.token }
end
def job_token_header(value = nil)
{ Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER => value || ci_build.token }
end
describe 'PUT /api/v4/projects/:id/packages/generic/mypackage/0.0.1/myfile.tar.gz/authorize' do
context 'with valid project' do
using RSpec::Parameterized::TableSyntax
where(:project_visibility, :user_role, :member?, :authenticate_with, :expected_status) do
'PUBLIC' | :developer | true | :personal_access_token | :success
'PUBLIC' | :guest | true | :personal_access_token | :forbidden
'PUBLIC' | :developer | true | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :guest | true | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :developer | false | :personal_access_token | :forbidden
'PUBLIC' | :guest | false | :personal_access_token | :forbidden
'PUBLIC' | :developer | false | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :guest | false | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :anonymous | false | :none | :unauthorized
'PRIVATE' | :developer | true | :personal_access_token | :success
'PRIVATE' | :guest | true | :personal_access_token | :forbidden
'PRIVATE' | :developer | true | :invalid_personal_access_token | :unauthorized
'PRIVATE' | :guest | true | :invalid_personal_access_token | :unauthorized
'PRIVATE' | :developer | false | :personal_access_token | :not_found
'PRIVATE' | :guest | false | :personal_access_token | :not_found
'PRIVATE' | :developer | false | :invalid_personal_access_token | :unauthorized
'PRIVATE' | :guest | false | :invalid_personal_access_token | :unauthorized
'PRIVATE' | :anonymous | false | :none | :unauthorized
'PUBLIC' | :developer | true | :job_token | :success
'PUBLIC' | :developer | true | :invalid_job_token | :unauthorized
'PUBLIC' | :developer | false | :job_token | :forbidden
'PUBLIC' | :developer | false | :invalid_job_token | :unauthorized
'PRIVATE' | :developer | true | :job_token | :success
'PRIVATE' | :developer | true | :invalid_job_token | :unauthorized
'PRIVATE' | :developer | false | :job_token | :not_found
'PRIVATE' | :developer | false | :invalid_job_token | :unauthorized
end
with_them do
before do before do
project.add_developer(user) project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility, false))
project.send("add_#{user_role}", user) if member? && user_role != :anonymous
end end
context 'packages feature is disabled' do it "responds with #{params[:expected_status]}" do
it 'responds with 404 Not Found' do headers = workhorse_header.merge(auth_header)
stub_packages_setting(enabled: false) url = "/projects/#{project.id}/packages/generic/mypackage/0.0.1/myfile.tar.gz/authorize"
ping(personal_access_token: auth_token) put api(url), headers: headers
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(expected_status)
end
end end
end end
it 'rejects a malicious request' do
project.add_developer(user)
headers = workhorse_header.merge(personal_access_token_header)
url = "/projects/#{project.id}/packages/generic/mypackage/0.0.1/%2e%2e%2f.ssh%2fauthorized_keys/authorize"
put api(url), headers: headers
expect(response).to have_gitlab_http_status(:bad_request)
end
context 'generic_packages feature flag is disabled' do context 'generic_packages feature flag is disabled' do
it 'responds with 404 Not Found' do it 'responds with 404 Not Found' do
stub_feature_flags(generic_packages: false) stub_feature_flags(generic_packages: false)
project.add_developer(user)
headers = workhorse_header.merge(personal_access_token_header)
url = "/projects/#{project.id}/packages/generic/mypackage/0.0.1/myfile.tar.gz/authorize"
ping(personal_access_token: auth_token) put api(url), headers: headers
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(:not_found)
end end
end end
end
describe 'PUT /api/v4/projects/:id/packages/generic/mypackage/0.0.1/myfile.tar.gz' do
include WorkhorseHelpers
let(:file_upload) { fixture_file_upload('spec/fixtures/packages/generic/myfile.tar.gz') }
let(:params) { { file: file_upload } }
context 'authentication' do
using RSpec::Parameterized::TableSyntax
where(:project_visibility, :user_role, :member?, :authenticate_with, :expected_status) do
'PUBLIC' | :guest | true | :personal_access_token | :forbidden
'PUBLIC' | :developer | true | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :guest | true | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :developer | false | :personal_access_token | :forbidden
'PUBLIC' | :guest | false | :personal_access_token | :forbidden
'PUBLIC' | :developer | false | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :guest | false | :invalid_personal_access_token | :unauthorized
'PUBLIC' | :anonymous | false | :none | :unauthorized
'PRIVATE' | :guest | true | :personal_access_token | :forbidden
'PRIVATE' | :developer | true | :invalid_personal_access_token | :unauthorized
'PRIVATE' | :guest | true | :invalid_personal_access_token | :unauthorized
'PRIVATE' | :developer | false | :personal_access_token | :not_found
'PRIVATE' | :guest | false | :personal_access_token | :not_found
'PRIVATE' | :developer | false | :invalid_personal_access_token | :unauthorized
'PRIVATE' | :guest | false | :invalid_personal_access_token | :unauthorized
'PRIVATE' | :anonymous | false | :none | :unauthorized
'PUBLIC' | :developer | true | :invalid_job_token | :unauthorized
'PUBLIC' | :developer | false | :job_token | :forbidden
'PUBLIC' | :developer | false | :invalid_job_token | :unauthorized
'PRIVATE' | :developer | true | :invalid_job_token | :unauthorized
'PRIVATE' | :developer | false | :job_token | :not_found
'PRIVATE' | :developer | false | :invalid_job_token | :unauthorized
end
with_them do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility, false))
project.send("add_#{user_role}", user) if member? && user_role != :anonymous
end
it "responds with #{params[:expected_status]}" do
headers = workhorse_header.merge(auth_header)
upload_file(params, headers)
expect(response).to have_gitlab_http_status(expected_status)
end
end
end
context 'generic_packages feature flag is enabled' do context 'when user can upload packages and has valid credentials' do
before do before do
stub_feature_flags(generic_packages: true) project.add_developer(user)
end end
context 'authenticating using personal access token' do it 'creates package and package file when valid personal access token is used' do
it 'responds with 200 OK when valid personal access token is provided' do headers = workhorse_header.merge(personal_access_token_header)
ping(personal_access_token: auth_token)
expect { upload_file(params, headers) }
.to change { project.packages.generic.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
expect(response).to have_gitlab_http_status(:ok) aggregate_failures do
expect(response).to have_gitlab_http_status(:created)
package = project.packages.generic.last
expect(package.name).to eq('mypackage')
expect(package.version).to eq('0.0.1')
expect(package.build_info).to be_nil
package_file = package.package_files.last
expect(package_file.file_name).to eq('myfile.tar.gz')
end
end end
it 'responds with 401 Unauthorized when invalid personal access token provided' do it 'creates package, package file, and package build info when valid job token is used' do
ping(personal_access_token: 'invalid-token') headers = workhorse_header.merge(job_token_header)
expect(response).to have_gitlab_http_status(:unauthorized) expect { upload_file(params, headers) }
.to change { project.packages.generic.count }.by(1)
.and change { Packages::PackageFile.count }.by(1)
aggregate_failures do
expect(response).to have_gitlab_http_status(:created)
package = project.packages.generic.last
expect(package.name).to eq('mypackage')
expect(package.version).to eq('0.0.1')
expect(package.build_info.pipeline).to eq(ci_build.pipeline)
package_file = package.package_files.last
expect(package_file.file_name).to eq('myfile.tar.gz')
end end
end end
context 'authenticating using job token' do context 'event tracking' do
it 'responds with 200 OK when valid job token is provided' do subject { upload_file(params, workhorse_header.merge(personal_access_token_header)) }
job_token = create(:ci_build, :running, user: user).token
ping(job_token: job_token) it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
end
expect(response).to have_gitlab_http_status(:ok) it 'rejects request without a file from workhorse' do
headers = workhorse_header.merge(personal_access_token_header)
upload_file({}, headers)
expect(response).to have_gitlab_http_status(:bad_request)
end end
it 'responds with 401 Unauthorized when invalid job token provided' do it 'rejects request without an auth token' do
ping(job_token: 'invalid-token') upload_file(params, workhorse_header)
expect(response).to have_gitlab_http_status(:unauthorized) expect(response).to have_gitlab_http_status(:unauthorized)
end end
it 'rejects request without workhorse rewritten fields' do
headers = workhorse_header.merge(personal_access_token_header)
upload_file(params, headers, send_rewritten_field: false)
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'rejects request if file size is too large' do
allow_next_instance_of(UploadedFile) do |uploaded_file|
allow(uploaded_file).to receive(:size).and_return(project.actual_limits.generic_packages_max_file_size + 1)
end
headers = workhorse_header.merge(personal_access_token_header)
upload_file(params, headers)
expect(response).to have_gitlab_http_status(:bad_request)
end
it 'rejects request without workhorse header' do
expect(Gitlab::ErrorTracking).to receive(:track_exception).once
upload_file(params, personal_access_token_header)
expect(response).to have_gitlab_http_status(:forbidden)
end
it 'rejects a malicious request' do
headers = workhorse_header.merge(personal_access_token_header)
upload_file(params, headers, file_name: '%2e%2e%2f.ssh%2fauthorized_keys')
expect(response).to have_gitlab_http_status(:bad_request)
end end
end end
def ping(personal_access_token: nil, job_token: nil) def upload_file(params, request_headers, send_rewritten_field: true, file_name: 'myfile.tar.gz')
headers = { url = "/projects/#{project.id}/packages/generic/mypackage/0.0.1/#{file_name}"
Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => personal_access_token.presence,
Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER => job_token.presence
}.compact
get api('/projects/%d/packages/generic/ping' % project.id), headers: headers workhorse_finalize(
api(url),
method: :put,
file_key: :file,
params: params,
headers: request_headers,
send_rewritten_field: send_rewritten_field
)
end end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Packages::Generic::CreatePackageFileService do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
describe '#execute' do
let(:sha256) { '440e5e148a25331bbd7991575f7d54933c0ebf6cc735a18ee5066ac1381bb590' }
let(:temp_file) { Tempfile.new("test") }
let(:file) { UploadedFile.new(temp_file.path, sha256: sha256) }
let(:package) { create(:generic_package, project: project) }
let(:params) do
{
package_name: 'mypackage',
package_version: '0.0.1',
file: file,
file_name: 'myfile.tar.gz.1'
}
end
before do
FileUtils.touch(temp_file)
end
after do
FileUtils.rm_f(temp_file)
end
it 'creates package file' do
package_service = double
package_params = {
name: params[:package_name],
version: params[:package_version],
build: params[:build]
}
expect(::Packages::Generic::FindOrCreatePackageService).to receive(:new).with(project, user, package_params).and_return(package_service)
expect(package_service).to receive(:execute).and_return(package)
service = described_class.new(project, user, params)
expect { service.execute }.to change { package.package_files.count }.by(1)
package_file = package.package_files.last
aggregate_failures do
expect(package_file.package).to eq(package)
expect(package_file.file_name).to eq('myfile.tar.gz.1')
expect(package_file.size).to eq(file.size)
expect(package_file.file_sha256).to eq(sha256)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Packages::Generic::FindOrCreatePackageService do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:ci_build) { create(:ci_build, :running, user: user) }
let(:params) do
{
name: 'mypackage',
version: '0.0.1'
}
end
describe '#execute' do
context 'when packages does not exist yet' do
it 'creates package' do
service = described_class.new(project, user, params)
expect { service.execute }.to change { project.packages.generic.count }.by(1)
package = project.packages.generic.last
aggregate_failures do
expect(package.creator).to eq(user)
expect(package.name).to eq('mypackage')
expect(package.version).to eq('0.0.1')
expect(package.build_info).to be_nil
end
end
it 'creates package and package build info when build is provided' do
service = described_class.new(project, user, params.merge(build: ci_build))
expect { service.execute }.to change { project.packages.generic.count }.by(1)
package = project.packages.generic.last
aggregate_failures do
expect(package.creator).to eq(user)
expect(package.name).to eq('mypackage')
expect(package.version).to eq('0.0.1')
expect(package.build_info.pipeline).to eq(ci_build.pipeline)
end
end
end
context 'when packages already exists' do
let!(:package) { project.packages.generic.create!(params) }
context 'when package was created manually' do
it 'finds the package and does not create package build info even if build is provided' do
service = described_class.new(project, user, params.merge(build: ci_build))
expect do
found_package = service.execute
expect(found_package).to eq(package)
end.not_to change { project.packages.generic.count }
expect(package.reload.build_info).to be_nil
end
end
context 'when package was created by pipeline' do
let(:pipeline) { create(:ci_pipeline, project: project) }
before do
package.create_build_info!(pipeline: pipeline)
end
it 'finds the package and does not change package build info even if build is provided' do
service = described_class.new(project, user, params.merge(build: ci_build))
expect do
found_package = service.execute
expect(found_package).to eq(package)
end.not_to change { project.packages.generic.count }
expect(package.reload.build_info.pipeline).to eq(pipeline)
end
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