Commit aca86a41 authored by David Fernandez's avatar David Fernandez

Add Nuget Packages API basic version

Gated behind a feature flag
Added authentication based on http basic auth
Added Service index response
parent bd3aa45c
# frozen_string_literal: true
module Packages
module Nuget
class ServiceIndexPresenter
include API::Helpers::RelatedResourcesHelpers
attr_reader :project
SERVICE_VERSIONS = {
download: %w[PackageBaseAddress/3.0.0],
search: %w[SearchQueryService SearchQueryService/3.0.0-beta SearchQueryService/3.0.0-rc],
publish: %w[PackagePublish/2.0.0],
metadata: %w[RegistrationsBaseUrl RegistrationsBaseUrl/3.0.0-beta RegistrationsBaseUrl/3.0.0-rc]
}.freeze
SERVICE_COMMENTS = {
download: 'Get package content (.nupkg).',
search: 'Filter and search for packages by keyword.',
publish: 'Push and delete (or unlist) packages.',
metadata: 'Get package metadata.'
}.freeze
VERSION = '3.0.0'.freeze
def initialize(project)
@project = project
end
def version
VERSION
end
def resources
[
build_service(:download),
build_service(:search),
build_service(:publish),
build_service(:metadata)
].flatten
end
private
def build_service(service_type)
url = build_service_url(service_type)
comment = SERVICE_COMMENTS[service_type]
SERVICE_VERSIONS[service_type].map do |version|
{ :@id => url, :@type => version, :comment => comment }
end
end
def build_service_url(service_type)
base_path = "#{api_v4_projects_path(id: project.id)}/packages/nuget"
full_path = case service_type
when :download
"#{base_path}/download" # TODO NUGET API: replace with grape path helper when download endpoint is implemented
when :search
"#{base_path}/query" # TODO NUGET API: replace with grape path helper when query endpoint is implemented
when :metadata
"#{base_path}/metadata" # TODO NUGET API: replace with grape path helper when metadata endpoint is implemented
when :publish
base_path # TODO NUGET API: replace with grape path helper when publish endpoint is implemented
end
expose_url(full_path)
end
end
end
end
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
# Technical debt: https://gitlab.com/gitlab-org/gitlab/issues/35798 # Technical debt: https://gitlab.com/gitlab-org/gitlab/issues/35798
module API module API
class ConanPackages < Grape::API class ConanPackages < Grape::API
helpers ::API::Helpers::PackagesHelpers helpers ::API::Helpers::PackagesManagerClientsHelpers
PACKAGE_REQUIREMENTS = { PACKAGE_REQUIREMENTS = {
package_name: API::NO_SLASH_URL_PART_REGEX, package_name: API::NO_SLASH_URL_PART_REGEX,
...@@ -415,7 +415,7 @@ module API ...@@ -415,7 +415,7 @@ module API
def find_personal_access_token def find_personal_access_token
personal_access_token = find_personal_access_token_from_conan_jwt || personal_access_token = find_personal_access_token_from_conan_jwt ||
find_personal_access_token_from_conan_http_basic_auth find_personal_access_token_from_http_basic_auth
personal_access_token || unauthorized! personal_access_token || unauthorized!
end end
...@@ -434,14 +434,6 @@ module API ...@@ -434,14 +434,6 @@ module API
PersonalAccessToken.find_by_id_and_user_id(token.personal_access_token_id, token.user_id) PersonalAccessToken.find_by_id_and_user_id(token.personal_access_token_id, token.user_id)
end end
def find_personal_access_token_from_conan_http_basic_auth
encoded_credentials = headers['Authorization'].to_s.split('Basic ', 2).second
token = Base64.decode64(encoded_credentials || '').split(':', 2).second
return unless token
PersonalAccessToken.find_by_token(token)
end
end end
end end
end end
# frozen_string_literal: true
module API
module Helpers
module PackagesManagerClientsHelpers
include ::API::Helpers::PackagesHelpers
def find_personal_access_token_from_http_basic_auth
return unless headers
encoded_credentials = headers['Authorization'].to_s.split('Basic ', 2).second
token = Base64.decode64(encoded_credentials || '').split(':', 2).second
return unless token
PersonalAccessToken.find_by_token(token)
end
end
end
end
# frozen_string_literal: true
# NuGet Package Manager Client API
#
# These API endpoints are not meant to be consumed directly by users. They are
# called by the NuGet package manager client when users run commands
# like `nuget install` or `nuget push`.
module API
class NugetPackages < Grape::API
helpers ::API::Helpers::PackagesManagerClientsHelpers
AUTHORIZATION_HEADER = 'Authorization'
AUTHENTICATE_REALM_HEADER = 'Www-Authenticate: Basic realm'
AUTHENTICATE_REALM_NAME = 'GitLab Nuget Package Registry'
POSITIVE_INTEGER_REGEX = %r{\A[1-9]\d*\z}.freeze
default_format :json
helpers do
def find_personal_access_token
find_personal_access_token_from_http_basic_auth
end
def authorized_user_project
@authorized_user_project ||= authorized_project_find!(params[:id])
end
def authorized_project_find!(id)
project = find_project(id)
unless project && can?(current_user, :read_project, project)
return current_user ? not_found! : unauthorized_with_header!
end
project
end
def unauthorized_with_header!
header(AUTHENTICATE_REALM_HEADER, AUTHENTICATE_REALM_NAME)
unauthorized!
end
end
before do
require_packages_enabled!
authenticate_non_get!
end
params do
requires :id, type: String, desc: 'The ID of a project', regexp: POSITIVE_INTEGER_REGEX
end
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
before do
not_found! if Feature.disabled?(:nuget_package_registry, authorized_user_project)
authorize_packages_feature!(authorized_user_project)
end
namespace ':id/packages/nuget' do
# https://docs.microsoft.com/en-us/nuget/api/service-index
desc 'The NuGet Service Index' do
detail 'This feature was introduced in GitLab 12.6'
end
get 'index', format: :json do
authorize_read_package!(authorized_user_project)
present ::Packages::Nuget::ServiceIndexPresenter.new(authorized_user_project),
with: EE::API::Entities::Nuget::ServiceIndex
end
end
end
end
end
...@@ -30,6 +30,7 @@ module EE ...@@ -30,6 +30,7 @@ module EE
mount ::API::License mount ::API::License
mount ::API::ProjectMirror mount ::API::ProjectMirror
mount ::API::ProjectPushRule mount ::API::ProjectPushRule
mount ::API::NugetPackages
mount ::API::ConanPackages mount ::API::ConanPackages
mount ::API::MavenPackages mount ::API::MavenPackages
mount ::API::NpmPackages mount ::API::NpmPackages
......
...@@ -845,6 +845,13 @@ module EE ...@@ -845,6 +845,13 @@ module EE
end end
end end
module Nuget
class ServiceIndex < Grape::Entity
expose :version
expose :resources
end
end
class NpmPackage < Grape::Entity class NpmPackage < Grape::Entity
expose :name expose :name
expose :versions expose :versions
......
{
"type": "object",
"required": ["version", "resources"],
"properties": {
"version": { "const": "3.0.0" },
"resources": {
"type": "array",
"items": {
"type": "object",
"required": ["@id", "@type", "comment"],
"properties": {
"@id": { "type": "string" },
"@type": { "type": "string" },
"comment": { "type": "string" }
}
}
}
}
}
# frozen_string_literal: true
require 'spec_helper'
describe API::Helpers::PackagesManagerClientsHelpers do
let_it_be(:personal_access_token) { create(:personal_access_token) }
let_it_be(:username) { personal_access_token.user.username }
let(:password) { personal_access_token.token }
describe '#find_personal_access_token_from_http_basic_auth' do
let(:headers) { { Authorization: basic_http_auth(username, password) } }
let(:helper) { Class.new.include(described_class).new }
subject { helper.find_personal_access_token_from_http_basic_auth }
before do
allow(helper).to receive(:headers).and_return(headers&.with_indifferent_access)
end
context 'with a valid Authorization header' do
it { is_expected.to eq personal_access_token }
end
context 'with an invalid Authorization header' do
where(:headers) do
[
[{ Authorization: 'Invalid' }],
[{}],
[nil]
]
end
with_them do
it { is_expected.to be nil }
end
end
context 'with an unknown Authorization header' do
let(:password) { 'Unknown' }
it { is_expected.to be nil }
end
end
def basic_http_auth(username, password)
ActionController::HttpAuthentication::Basic.encode_credentials(username, password)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Packages::Nuget::ServiceIndexPresenter do
let_it_be(:project) { create(:project) }
let_it_be(:presenter) { described_class.new(project) }
describe '#version' do
subject { presenter.version }
it { is_expected.to eq '3.0.0' }
end
describe '#resources' do
subject { presenter.resources }
it 'has valid resources' do
expect(subject.size).to eq 8
subject.each do |resource|
%i[@id @type comment].each do |field|
expect(resource).to have_key(field)
expect(resource[field]).to be_a(String)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe API::NugetPackages do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
describe 'GET /api/v4/projects/:id/packages/nuget' do
let(:url) { "/projects/#{project.id}/packages/nuget/index.json" }
subject { get api(url) }
context 'with packages features enabled' do
before do
stub_licensed_features(packages: true)
end
context 'with feature flag enabled' do
before do
stub_feature_flags(nuget_package_registry: { enabled: true, thing: project })
end
context 'with valid project' do
using RSpec::Parameterized::TableSyntax
where(:project_visibility_level, :user_role, :member, :wrong_token, :shared_examples_name, :expected_status) do
'PUBLIC' | :developer | true | false | 'returns nuget service index' | :success
'PUBLIC' | :guest | true | false | 'returns nuget service index' | :success
'PUBLIC' | :developer | true | true | 'returns nuget service index' | :success
'PUBLIC' | :guest | true | true | 'returns nuget service index' | :success
'PUBLIC' | :developer | false | false | 'returns nuget service index' | :success
'PUBLIC' | :guest | false | false | 'returns nuget service index' | :success
'PUBLIC' | :developer | false | true | 'returns nuget service index' | :success
'PUBLIC' | :guest | false | true | 'returns nuget service index' | :success
'PUBLIC' | :anonymous | false | false | 'returns nuget service index' | :success
'PRIVATE' | :developer | true | false | 'returns nuget service index' | :success
'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :forbidden
'PRIVATE' | :developer | true | true | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :not_found
'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :not_found
'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :unauthorized
'PRIVATE' | :anonymous | false | false | 'rejects nuget packages access' | :unauthorized
end
with_them do
let(:token) { wrong_token ? 'wrong' : personal_access_token.token }
let(:headers) { user_role == :anonymous ? {} : build_auth_headers(basic_http_auth(user.username, token)) }
subject { get api(url), headers: headers }
before do
project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false))
end
after do
project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
end
it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member]
end
end
context 'with an unknown project' do
let(:project) { OpenStruct.new(id: 1234567890) }
context 'as anonymous' do
it_behaves_like 'rejects nuget packages access', :anonymous, :unauthorized
end
context 'as authenticated user' do
subject { get api(url), headers: build_auth_headers(basic_http_auth(user.username, personal_access_token.token)) }
it_behaves_like 'rejects nuget packages access', :anonymous, :not_found
end
end
context 'with a project id with invalid integers' do
using RSpec::Parameterized::TableSyntax
let(:project) { OpenStruct.new(id: id) }
where(:id, :status) do
'/../' | :unauthorized
'' | :not_found
'%20' | :unauthorized
'%2e%2e%2f' | :unauthorized
'NaN' | :unauthorized
00002345 | :unauthorized
'anything25' | :unauthorized
end
with_them do
it_behaves_like 'rejects nuget packages access', :anonymous, params[:status]
end
end
context 'with invalid format' do
let(:url) { "/projects/#{project.id}/packages/nuget/index.xls" }
it_behaves_like 'rejects nuget packages access', :anonymous, :not_found
end
end
context 'with feature flag disabled' do
before do
stub_feature_flags(nuget_package_registry: { enabled: false, thing: project })
end
it_behaves_like 'rejects nuget packages access', :anonymous, :not_found
end
end
context 'with packages features disabled' do
before do
stub_licensed_features(packages: false)
end
it_behaves_like 'rejects nuget packages access', :anonymous, :forbidden
end
end
def build_auth_headers(value)
{ 'HTTP_AUTHORIZATION' => value }
end
def basic_http_auth(username, password)
ActionController::HttpAuthentication::Basic.encode_credentials(username, password)
end
end
# frozen_string_literal: true
shared_examples 'rejects nuget packages access' do |user_type, status, add_member = true|
context "for user type #{user_type}" do
before do
project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
end
it_behaves_like 'returning response status', status
if status == :unauthorized
it 'has the correct response header' do
subject
expect(response.headers['Www-Authenticate: Basic realm']).to eq 'GitLab Nuget Package Registry'
end
end
end
end
shared_examples 'returns nuget service index' do |user_type, status, add_member = true|
context "for user type #{user_type}" do
before do
project.send("add_#{user_type}", user) if add_member && user_type != :anonymous
end
it_behaves_like 'returning response status', status
it 'returns a valid json response' do
subject
expect(response.content_type.to_s).to eq('application/json')
expect(json_response).to be_a(Hash)
end
it 'returns a valid nuget service index json' do
subject
expect(json_response).to match_schema('public_api/v4/packages/nuget/service_index', dir: 'ee')
end
end
end
# frozen_string_literal: true
shared_examples 'returning response status' do |status|
it "returns #{status}" do
subject
expect(response).to have_gitlab_http_status(status)
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