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
.with_package_type(package_type)
.safe_find_or_create_by!(name: name, version: version) do |pkg|
pkg.creator = package_creator
yield pkg if block_given?
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 @@
module API
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
require_packages_enabled!
authenticate!
......@@ -17,17 +22,71 @@ module API
route_setting :authentication, job_token_allowed: true
namespace ':id/packages/generic' do
get 'ping' do
:pong
namespace ':package_name/*package_version/:file_name', requirements: GENERIC_PACKAGES_REQUIREMENTS do
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
helpers do
include ::API::Helpers::PackagesHelpers
include ::API::Helpers::Packages::BasicAuthHelpers
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
......
......@@ -103,6 +103,10 @@ module Gitlab
def generic_package_version_regex
/\A\d+\.\d+\.\d+\z/
end
def generic_package_file_name_regex
maven_file_name_regex
end
end
extend self
......
......@@ -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('') }
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
......@@ -4,79 +4,268 @@ require 'spec_helper'
RSpec.describe API::GenericPackages do
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') }
let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } }
let(:user) { personal_access_token.user }
let(:ci_build) { create(:ci_build, :running, user: user) }
describe 'GET /api/v4/projects/:id/packages/generic/ping' do
let(:user) { personal_access_token.user }
let(:auth_token) { personal_access_token.token }
def auth_header
return {} if user_role == :anonymous
before do
project.add_developer(user)
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
context 'packages feature is disabled' do
it 'responds with 404 Not Found' do
stub_packages_setting(enabled: false)
def personal_access_token_header(value = nil)
{ Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => value || personal_access_token.token }
end
ping(personal_access_token: auth_token)
def job_token_header(value = nil)
{ Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER => value || ci_build.token }
end
expect(response).to have_gitlab_http_status(:not_found)
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
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)
url = "/projects/#{project.id}/packages/generic/mypackage/0.0.1/myfile.tar.gz/authorize"
put api(url), headers: headers
expect(response).to have_gitlab_http_status(expected_status)
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
it 'responds with 404 Not Found' do
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)
end
end
end
context 'generic_packages feature flag is enabled' do
before do
stub_feature_flags(generic_packages: true)
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
context 'authenticating using personal access token' do
it 'responds with 200 OK when valid personal access token is provided' do
ping(personal_access_token: auth_token)
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)
expect(response).to have_gitlab_http_status(:ok)
upload_file(params, headers)
expect(response).to have_gitlab_http_status(expected_status)
end
end
end
it 'responds with 401 Unauthorized when invalid personal access token provided' do
ping(personal_access_token: 'invalid-token')
context 'when user can upload packages and has valid credentials' do
before do
project.add_developer(user)
end
it 'creates package and package file when valid personal access token is used' do
headers = workhorse_header.merge(personal_access_token_header)
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(:unauthorized)
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
context 'authenticating using job token' do
it 'responds with 200 OK when valid job token is provided' do
job_token = create(:ci_build, :running, user: user).token
it 'creates package, package file, and package build info when valid job token is used' do
headers = workhorse_header.merge(job_token_header)
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)
ping(job_token: job_token)
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)
expect(response).to have_gitlab_http_status(:ok)
package_file = package.package_files.last
expect(package_file.file_name).to eq('myfile.tar.gz')
end
end
context 'event tracking' do
subject { upload_file(params, workhorse_header.merge(personal_access_token_header)) }
it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package'
end
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
it 'responds with 401 Unauthorized when invalid job token provided' do
ping(job_token: 'invalid-token')
it 'rejects request without an auth token' do
upload_file(params, workhorse_header)
expect(response).to have_gitlab_http_status(:unauthorized)
expect(response).to have_gitlab_http_status(:unauthorized)
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
def ping(personal_access_token: nil, job_token: nil)
headers = {
Gitlab::Auth::AuthFinders::PRIVATE_TOKEN_HEADER => personal_access_token.presence,
Gitlab::Auth::AuthFinders::JOB_TOKEN_HEADER => job_token.presence
}.compact
def upload_file(params, request_headers, send_rewritten_field: true, file_name: 'myfile.tar.gz')
url = "/projects/#{project.id}/packages/generic/mypackage/0.0.1/#{file_name}"
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
# 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