Merge branch 'docker-registry' into 'master'

Added authentication service for docker registry

This adds a simple authentication service for docker which uses current user credentials to authenticate pulls and pushes.

I have only one concern. Since the `.docker/config` is unencrypted, thus the password for user stored there is unencrypted, maybe we should from the start implement function to generate/provide a separate password just for the purposes of accessing docker registry?

What do you think @jacobvosmaer @sytses @marin?

cc @marin 

See merge request !3787
This commit is contained in:
Douwe Maan 2016-05-16 17:20:31 +00:00
commit 59e62fc486
24 changed files with 677 additions and 10 deletions

View file

@ -25,6 +25,7 @@ v 8.8.0 (unreleased)
- Update SVG sanitizer to conform to SVG 1.1
- Speed up push emails with multiple recipients by only generating the email once
- Updated search UI
- Added authentication service for Container Registry
- Display informative message when new milestone is created
- Sanitize milestones and labels titles
- Support multi-line tag messages. !3833 (Calin Seciu)

View file

@ -36,6 +36,7 @@ gem 'omniauth-shibboleth', '~> 1.2.0'
gem 'omniauth-twitter', '~> 1.2.0'
gem 'omniauth_crowd', '~> 2.2.0'
gem 'rack-oauth2', '~> 1.2.1'
gem 'jwt'
# Spam and anti-bot protection
gem 'recaptcha', require: 'recaptcha/rails'
@ -224,6 +225,7 @@ gem 'request_store', '~> 1.3.0'
gem 'select2-rails', '~> 3.5.9'
gem 'virtus', '~> 1.0.1'
gem 'net-ssh', '~> 3.0.1'
gem 'base32', '~> 0.3.0'
# Sentry integration
gem 'sentry-raven', '~> 0.15'

View file

@ -70,6 +70,7 @@ GEM
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
babosa (1.0.2)
base32 (0.3.2)
bcrypt (3.1.10)
benchmark-ips (2.3.0)
better_errors (1.0.1)
@ -893,6 +894,7 @@ DEPENDENCIES
attr_encrypted (~> 1.3.4)
awesome_print (~> 1.2.0)
babosa (~> 1.0.2)
base32 (~> 0.3.0)
benchmark-ips
better_errors (~> 1.0.1)
binding_of_caller (~> 0.7.2)
@ -954,6 +956,7 @@ DEPENDENCIES
jquery-rails (~> 4.1.0)
jquery-turbolinks (~> 2.1.0)
jquery-ui-rails (~> 5.0.0)
jwt
kaminari (~> 0.16.3)
letter_opener_web (~> 1.3.0)
licensee (~> 8.0.0)

View file

@ -0,0 +1,87 @@
class JwtController < ApplicationController
skip_before_action :authenticate_user!
skip_before_action :verify_authenticity_token
before_action :authenticate_project_or_user
SERVICES = {
Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService,
}
def auth
service = SERVICES[params[:service]]
return head :not_found unless service
result = service.new(@project, @user, auth_params).execute
render json: result, status: result[:http_status]
end
private
def authenticate_project_or_user
authenticate_with_http_basic do |login, password|
# if it's possible we first try to authenticate project with login and password
@project = authenticate_project(login, password)
return if @project
@user = authenticate_user(login, password)
return if @user
render_403
end
end
def auth_params
params.permit(:service, :scope, :offline_token, :account, :client_id)
end
def authenticate_project(login, password)
if login == 'gitlab_ci_token'
Project.find_by(builds_enabled: true, runners_token: password)
end
end
def authenticate_user(login, password)
# TODO: this is a copy and paste from grack_auth,
# it should be refactored in the future
user = Gitlab::Auth.new.find(login, password)
# If the user authenticated successfully, we reset the auth failure count
# from Rack::Attack for that IP. A client may attempt to authenticate
# with a username and blank password first, and only after it receives
# a 401 error does it present a password. Resetting the count prevents
# false positives from occurring.
#
# Otherwise, we let Rack::Attack know there was a failed authentication
# attempt from this IP. This information is stored in the Rails cache
# (Redis) and will be used by the Rack::Attack middleware to decide
# whether to block requests from this IP.
config = Gitlab.config.rack_attack.git_basic_auth
if config.enabled
if user
# A successful login will reset the auth failure count from this IP
Rack::Attack::Allow2Ban.reset(request.ip, config)
else
banned = Rack::Attack::Allow2Ban.filter(request.ip, config) do
# Unless the IP is whitelisted, return true so that Allow2Ban
# increments the counter (stored in Rails.cache) for the IP
if config.ip_whitelist.include?(request.ip)
false
else
true
end
end
if banned
Rails.logger.info "IP #{request.ip} failed to login " \
"as #{login} but has been temporarily banned from Git auth"
return
end
end
end
user
end
end

View file

@ -235,7 +235,8 @@ class ProjectsController < Projects::ApplicationController
def project_params
params.require(:project).permit(
:name, :path, :description, :issues_tracker, :tag_list, :runners_token,
:issues_enabled, :merge_requests_enabled, :snippets_enabled, :issues_tracker_id, :default_branch,
:issues_enabled, :merge_requests_enabled, :snippets_enabled, :container_registry_enabled,
:issues_tracker_id, :default_branch,
:wiki_enabled, :visibility_level, :import_url, :last_activity_at, :namespace_id, :avatar,
:builds_enabled, :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex,
:public_builds,

View file

@ -61,6 +61,7 @@ class Ability
:read_merge_request,
:read_note,
:read_commit_status,
:read_container_image,
:download_code
]
@ -203,6 +204,7 @@ class Ability
:admin_label,
:read_commit_status,
:read_build,
:read_container_image,
]
end
@ -216,7 +218,9 @@ class Ability
:update_build,
:create_merge_request,
:create_wiki,
:push_code
:push_code,
:create_container_image,
:update_container_image,
]
end
@ -242,7 +246,8 @@ class Ability
:admin_wiki,
:admin_project,
:admin_commit_status,
:admin_build
:admin_build,
:admin_container_image,
]
end
@ -287,6 +292,10 @@ class Ability
rules += named_abilities('build')
end
unless project.container_registry_enabled
rules += named_abilities('container_image')
end
rules
end

View file

@ -22,6 +22,7 @@ class Project < ActiveRecord::Base
default_value_for :builds_enabled, gitlab_config_features.builds
default_value_for :wiki_enabled, gitlab_config_features.wiki
default_value_for :snippets_enabled, gitlab_config_features.snippets
default_value_for :container_registry_enabled, gitlab_config_features.container_registry
default_value_for(:shared_runners_enabled) { current_application_settings.shared_runners_enabled }
# set last_activity_at to the same as created_at
@ -327,6 +328,12 @@ class Project < ActiveRecord::Base
@repository ||= Repository.new(path_with_namespace, self)
end
def container_registry_url
if container_registry_enabled? && Gitlab.config.registry.enabled
"#{Gitlab.config.registry.host_with_port}/#{path_with_namespace}"
end
end
def commit(id = 'HEAD')
repository.commit(id)
end

View file

@ -0,0 +1,70 @@
module Auth
class ContainerRegistryAuthenticationService < BaseService
AUDIENCE = 'container_registry'
def execute
return error('not found', 404) unless registry.enabled
if params[:offline_token]
return error('forbidden', 403) unless current_user
else
return error('forbidden', 403) unless scope
end
{ token: authorized_token(scope).encoded }
end
private
def authorized_token(*accesses)
token = JSONWebToken::RSAToken.new(registry.key)
token.issuer = registry.issuer
token.audience = params[:service]
token.subject = current_user.try(:username)
token[:access] = accesses.compact
token
end
def scope
return unless params[:scope]
@scope ||= process_scope(params[:scope])
end
def process_scope(scope)
type, name, actions = scope.split(':', 3)
actions = actions.split(',')
return unless type == 'repository'
process_repository_access(type, name, actions)
end
def process_repository_access(type, name, actions)
requested_project = Project.find_with_namespace(name)
return unless requested_project
actions = actions.select do |action|
can_access?(requested_project, action)
end
{ type: type, name: name, actions: actions } if actions.present?
end
def can_access?(requested_project, requested_action)
return false unless requested_project.container_registry_enabled?
case requested_action
when 'pull'
requested_project == project || can?(current_user, :read_container_image, requested_project)
when 'push'
requested_project == project || can?(current_user, :create_container_image, requested_project)
else
false
end
end
def registry
Gitlab.config.registry
end
end
end

View file

@ -84,6 +84,16 @@
%br
%span.descr Share code pastes with others out of git repository
- if Gitlab.config.registry.enabled
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :container_registry_enabled do
= f.check_box :container_registry_enabled
%strong Container Registry
%br
%span.descr Enable Container Registry for this repository
= render 'builds_settings', f: f
%fieldset.features

View file

@ -98,6 +98,7 @@ production: &base
wiki: true
snippets: false
builds: true
container_registry: true
## Webhook settings
# Number of seconds to wait for HTTP response after sending webhook HTTP POST request (default: 10)
@ -175,6 +176,14 @@ production: &base
repository_archive_cache_worker:
cron: "0 * * * *"
registry:
# enabled: true
# host: registry.example.com
# port: 5000
# api_url: http://localhost:5000/
# key: config/registry.key
# issuer: omnibus-certificate
#
# 2. GitLab CI settings
# ==========================

View file

@ -206,12 +206,13 @@ Settings.gitlab['default_projects_features'] ||= {}
Settings.gitlab['webhook_timeout'] ||= 10
Settings.gitlab['max_attachment_size'] ||= 10
Settings.gitlab['session_expire_delay'] ||= 10080
Settings.gitlab.default_projects_features['issues'] = true if Settings.gitlab.default_projects_features['issues'].nil?
Settings.gitlab.default_projects_features['merge_requests'] = true if Settings.gitlab.default_projects_features['merge_requests'].nil?
Settings.gitlab.default_projects_features['wiki'] = true if Settings.gitlab.default_projects_features['wiki'].nil?
Settings.gitlab.default_projects_features['snippets'] = false if Settings.gitlab.default_projects_features['snippets'].nil?
Settings.gitlab.default_projects_features['builds'] = true if Settings.gitlab.default_projects_features['builds'].nil?
Settings.gitlab.default_projects_features['visibility_level'] = Settings.send(:verify_constant, Gitlab::VisibilityLevel, Settings.gitlab.default_projects_features['visibility_level'], Gitlab::VisibilityLevel::PRIVATE)
Settings.gitlab.default_projects_features['issues'] = true if Settings.gitlab.default_projects_features['issues'].nil?
Settings.gitlab.default_projects_features['merge_requests'] = true if Settings.gitlab.default_projects_features['merge_requests'].nil?
Settings.gitlab.default_projects_features['wiki'] = true if Settings.gitlab.default_projects_features['wiki'].nil?
Settings.gitlab.default_projects_features['snippets'] = false if Settings.gitlab.default_projects_features['snippets'].nil?
Settings.gitlab.default_projects_features['builds'] = true if Settings.gitlab.default_projects_features['builds'].nil?
Settings.gitlab.default_projects_features['container_registry'] = true if Settings.gitlab.default_projects_features['container_registry'].nil?
Settings.gitlab.default_projects_features['visibility_level'] = Settings.send(:verify_constant, Gitlab::VisibilityLevel, Settings.gitlab.default_projects_features['visibility_level'], Gitlab::VisibilityLevel::PRIVATE)
Settings.gitlab['repository_downloads_path'] = File.join(Settings.shared['path'], 'cache/archive') if Settings.gitlab['repository_downloads_path'].nil?
Settings.gitlab['restricted_signup_domains'] ||= []
Settings.gitlab['import_sources'] ||= ['github','bitbucket','gitlab','gitorious','google_code','fogbugz','git']
@ -242,6 +243,16 @@ Settings.artifacts['enabled'] = true if Settings.artifacts['enabled'].nil?
Settings.artifacts['path'] = File.expand_path(Settings.artifacts['path'] || File.join(Settings.shared['path'], "artifacts"), Rails.root)
Settings.artifacts['max_size'] ||= 100 # in megabytes
#
# Registry
#
Settings['registry'] ||= Settingslogic.new({})
Settings.registry['enabled'] ||= false
Settings.registry['host'] ||= "example.com"
Settings.registry['api_url'] ||= "http://localhost:5000/"
Settings.registry['key'] ||= nil
Settings.registry['issuer'] ||= nil
#
# Git LFS
#

View file

@ -64,6 +64,9 @@ Rails.application.routes.draw do
get 'search' => 'search#show'
get 'search/autocomplete' => 'search#autocomplete', as: :search_autocomplete
# JSON Web Token
get 'jwt/auth' => 'jwt#auth'
# API
API::API.logger Rails.logger
mount API::API => '/api'

View file

@ -0,0 +1,5 @@
class AddImagesEnabledForProject < ActiveRecord::Migration
def change
add_column :projects, :container_registry_enabled, :boolean
end
end

View file

@ -762,6 +762,7 @@ ActiveRecord::Schema.define(version: 20160509201028) do
t.integer "pushes_since_gc", default: 0
t.boolean "last_repository_check_failed"
t.datetime "last_repository_check_at"
t.boolean "container_registry_enabled"
end
add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree

View file

@ -424,6 +424,7 @@ Parameters:
- `builds_enabled` (optional)
- `wiki_enabled` (optional)
- `snippets_enabled` (optional)
- `container_registry_enabled` (optional)
- `public` (optional) - if `true` same as setting visibility_level = 20
- `visibility_level` (optional)
- `import_url` (optional)
@ -447,6 +448,7 @@ Parameters:
- `builds_enabled` (optional)
- `wiki_enabled` (optional)
- `snippets_enabled` (optional)
- `container_registry_enabled` (optional)
- `public` (optional) - if `true` same as setting visibility_level = 20
- `visibility_level` (optional)
- `import_url` (optional)
@ -472,6 +474,7 @@ Parameters:
- `builds_enabled` (optional)
- `wiki_enabled` (optional)
- `snippets_enabled` (optional)
- `container_registry_enabled` (optional)
- `public` (optional) - if `true` same as setting visibility_level = 20
- `visibility_level` (optional)
- `public_builds` (optional)

View file

@ -27,6 +27,7 @@ documentation](../workflow/add-user/add-user.md).
| Manage issue tracker | | ✓ | ✓ | ✓ | ✓ |
| Manage labels | | ✓ | ✓ | ✓ | ✓ |
| See a commit status | | ✓ | ✓ | ✓ | ✓ |
| See a container registry | | ✓ | ✓ | ✓ | ✓ |
| Manage merge requests | | | ✓ | ✓ | ✓ |
| Create new merge request | | | ✓ | ✓ | ✓ |
| Create new branches | | | ✓ | ✓ | ✓ |
@ -37,6 +38,7 @@ documentation](../workflow/add-user/add-user.md).
| Write a wiki | | | ✓ | ✓ | ✓ |
| Cancel and retry builds | | | ✓ | ✓ | ✓ |
| Create or update commit status | | | ✓ | ✓ | ✓ |
| Update a container registry | | | ✓ | ✓ | ✓ |
| Create new milestones | | | | ✓ | ✓ |
| Add new team members | | | | ✓ | ✓ |
| Push to protected branches | | | | ✓ | ✓ |

View file

@ -66,7 +66,8 @@ module API
expose :owner, using: Entities::UserBasic, unless: ->(project, options) { project.group }
expose :name, :name_with_namespace
expose :path, :path_with_namespace
expose :issues_enabled, :merge_requests_enabled, :wiki_enabled, :builds_enabled, :snippets_enabled, :created_at, :last_activity_at
expose :issues_enabled, :merge_requests_enabled, :wiki_enabled, :builds_enabled, :snippets_enabled, :container_registry_enabled
expose :created_at, :last_activity_at
expose :shared_runners_enabled
expose :creator_id
expose :namespace

View file

@ -94,6 +94,7 @@ module API
# builds_enabled (optional)
# wiki_enabled (optional)
# snippets_enabled (optional)
# container_registry_enabled (optional)
# shared_runners_enabled (optional)
# namespace_id (optional) - defaults to user namespace
# public (optional) - if true same as setting visibility_level = 20
@ -112,6 +113,7 @@ module API
:builds_enabled,
:wiki_enabled,
:snippets_enabled,
:container_registry_enabled,
:shared_runners_enabled,
:namespace_id,
:public,
@ -143,6 +145,7 @@ module API
# builds_enabled (optional)
# wiki_enabled (optional)
# snippets_enabled (optional)
# container_registry_enabled (optional)
# shared_runners_enabled (optional)
# public (optional) - if true same as setting visibility_level = 20
# visibility_level (optional)
@ -206,6 +209,7 @@ module API
# builds_enabled (optional)
# wiki_enabled (optional)
# snippets_enabled (optional)
# container_registry_enabled (optional)
# shared_runners_enabled (optional)
# public (optional) - if true same as setting visibility_level = 20
# visibility_level (optional) - visibility level of a project
@ -222,6 +226,7 @@ module API
:builds_enabled,
:wiki_enabled,
:snippets_enabled,
:container_registry_enabled,
:shared_runners_enabled,
:public,
:visibility_level,

View file

@ -0,0 +1,42 @@
module JSONWebToken
class RSAToken < Token
attr_reader :key_file
def initialize(key_file)
super()
@key_file = key_file
end
def encoded
headers = {
kid: kid
}
JWT.encode(payload, key, 'RS256', headers)
end
private
def key_data
@key_data ||= File.read(key_file)
end
def key
@key ||= OpenSSL::PKey::RSA.new(key_data)
end
def public_key
key.public_key
end
def kid
# calculate sha256 from DER encoded ASN1
kid = Digest::SHA256.digest(public_key.to_der)
# we encode only 30 bytes with base32
kid = Base32.encode(kid[0..29])
# insert colon every 4 characters
kid.scan(/.{4}/).join(':')
end
end
end

View file

@ -0,0 +1,46 @@
module JSONWebToken
class Token
attr_accessor :issuer, :subject, :audience, :id
attr_accessor :issued_at, :not_before, :expire_time
def initialize
@id = SecureRandom.uuid
@issued_at = Time.now
# we give a few seconds for time shift
@not_before = issued_at - 5.seconds
# default 60 seconds should be more than enough for this authentication token
@expire_time = issued_at + 1.minute
@custom_payload = {}
end
def [](key)
@custom_payload[key]
end
def []=(key, value)
@custom_payload[key] = value
end
def encoded
raise NotImplementedError
end
def payload
@custom_payload.merge(default_payload)
end
private
def default_payload
{
jti: id,
aud: audience,
sub: subject,
iss: issuer,
iat: issued_at.to_i,
nbf: not_before.to_i,
exp: expire_time.to_i
}.compact
end
end
end

View file

@ -0,0 +1,43 @@
describe JSONWebToken::RSAToken do
let(:rsa_key) do
OpenSSL::PKey::RSA.new <<-eos.strip_heredoc
-----BEGIN RSA PRIVATE KEY-----
MIIBOgIBAAJBAMA5sXIBE0HwgIB40iNidN4PGWzOyLQK0bsdOBNgpEXkDlZBvnak
OUgAPF+rME4PB0Yl415DabUI40T5UNmlwxcCAwEAAQJAZtY2pSwIFm3JAXIh0cZZ
iXcAfiJ+YzuqinUOS+eW2sBCAEzjcARlU/o6sFQgtsOi4FOMczAd1Yx8UDMXMmrw
2QIhAPBgVhJiTF09pdmeFWutCvTJDlFFAQNbrbo2X2x/9WF9AiEAzLgqMKeStSRu
H9N16TuDrUoO8R+DPqriCwkKrSHaWyMCIFzMhE4inuKcSywBaLmiG4m3GQzs++Al
A6PRG/PSTpQtAiBxtBg6zdf+JC3GH3zt/dA0/10tL4OF2wORfYQghRzyYQIhAL2l
0ZQW+yLIZAGrdBFWYEAa52GZosncmzBNlsoTgwE4
-----END RSA PRIVATE KEY-----
eos
end
let(:rsa_token) { described_class.new(nil) }
let(:rsa_encoded) { rsa_token.encoded }
before { allow_any_instance_of(described_class).to receive(:key).and_return(rsa_key) }
context 'token' do
context 'for valid key to be validated' do
before { rsa_token['key'] = 'value' }
subject { JWT.decode(rsa_encoded, rsa_key) }
it { expect{subject}.to_not raise_error }
it { expect(subject.first).to include('key' => 'value') }
it do
expect(subject.second).to eq(
"typ" => "JWT",
"alg" => "RS256",
"kid" => "OGXY:4TR7:FAVO:WEM2:XXEW:E4FP:TKL7:7ACK:TZAF:D54P:SUIA:P3B2")
end
end
context 'for invalid key to raise an exception' do
let(:new_key) { OpenSSL::PKey::RSA.generate(512) }
subject { JWT.decode(rsa_encoded, new_key) }
it { expect{subject}.to raise_error(JWT::DecodeError) }
end
end
end

View file

@ -0,0 +1,18 @@
describe JSONWebToken::Token do
let(:token) { described_class.new }
context 'custom parameters' do
let(:value) { 'value' }
before { token[:key] = value }
it { expect(token[:key]).to eq(value) }
it { expect(token.payload).to include(key: value) }
end
context 'embeds default payload' do
subject { token.payload }
let(:default) { token.send(:default_payload) }
it { is_expected.to include(default) }
end
end

View file

@ -0,0 +1,72 @@
require 'spec_helper'
describe JwtController do
let(:service) { double(execute: {}) }
let(:service_class) { double(new: service) }
let(:service_name) { 'test' }
let(:parameters) { { service: service_name } }
before { stub_const('JwtController::SERVICES', service_name => service_class) }
context 'existing service' do
subject! { get '/jwt/auth', parameters }
it { expect(response.status).to eq(200) }
context 'returning custom http code' do
let(:service) { double(execute: { http_status: 505 }) }
it { expect(response.status).to eq(505) }
end
end
context 'when using authorized request' do
context 'using CI token' do
let(:project) { create(:empty_project, runners_token: 'token', builds_enabled: builds_enabled) }
let(:headers) { { authorization: credentials('gitlab_ci_token', project.runners_token) } }
subject! { get '/jwt/auth', parameters, headers }
context 'project with enabled CI' do
let(:builds_enabled) { true }
it { expect(service_class).to have_received(:new).with(project, nil, parameters) }
end
context 'project with disabled CI' do
let(:builds_enabled) { false }
it { expect(response.status).to eq(403) }
end
end
context 'using User login' do
let(:user) { create(:user) }
let(:headers) { { authorization: credentials('user', 'password') } }
before { expect_any_instance_of(Gitlab::Auth).to receive(:find).with('user', 'password').and_return(user) }
subject! { get '/jwt/auth', parameters, headers }
it { expect(service_class).to have_received(:new).with(nil, user, parameters) }
end
context 'using invalid login' do
let(:headers) { { authorization: credentials('invalid', 'password') } }
subject! { get '/jwt/auth', parameters, headers }
it { expect(response.status).to eq(403) }
end
end
context 'unknown service' do
subject! { get '/jwt/auth', service: 'unknown' }
it { expect(response.status).to eq(404) }
end
def credentials(login, password)
ActionController::HttpAuthentication::Basic.encode_credentials(login, password)
end
end

View file

@ -0,0 +1,216 @@
require 'spec_helper'
describe Auth::ContainerRegistryAuthenticationService, services: true do
let(:current_project) { nil }
let(:current_user) { nil }
let(:current_params) { {} }
let(:rsa_key) { OpenSSL::PKey::RSA.generate(512) }
let(:registry_settings) do
{
enabled: true,
issuer: 'rspec',
key: nil
}
end
let(:payload) { JWT.decode(subject[:token], rsa_key).first }
subject { described_class.new(current_project, current_user, current_params).execute }
before do
allow(Gitlab.config.registry).to receive_messages(registry_settings)
allow_any_instance_of(JSONWebToken::RSAToken).to receive(:key).and_return(rsa_key)
end
shared_examples 'an authenticated' do
it { is_expected.to include(:token) }
it { expect(payload).to include('access') }
end
shared_examples 'a accessible' do
let(:access) do
[{
'type' => 'repository',
'name' => project.path_with_namespace,
'actions' => actions,
}]
end
it_behaves_like 'an authenticated'
it { expect(payload).to include('access' => access) }
end
shared_examples 'a pullable' do
it_behaves_like 'a accessible' do
let(:actions) { ['pull'] }
end
end
shared_examples 'a pushable' do
it_behaves_like 'a accessible' do
let(:actions) { ['push'] }
end
end
shared_examples 'a pullable and pushable' do
it_behaves_like 'a accessible' do
let(:actions) { ['pull', 'push'] }
end
end
shared_examples 'a forbidden' do
it { is_expected.to include(http_status: 403) }
it { is_expected.to_not include(:token) }
end
context 'user authorization' do
let(:project) { create(:project) }
let(:current_user) { create(:user) }
context 'allow to use offline_token' do
let(:current_params) do
{ offline_token: true }
end
it_behaves_like 'an authenticated'
end
context 'allow developer to push images' do
before { project.team << [current_user, :developer] }
let(:current_params) do
{ scope: "repository:#{project.path_with_namespace}:push" }
end
it_behaves_like 'a pushable'
end
context 'allow reporter to pull images' do
before { project.team << [current_user, :reporter] }
let(:current_params) do
{ scope: "repository:#{project.path_with_namespace}:pull" }
end
it_behaves_like 'a pullable'
end
context 'return a least of privileges' do
before { project.team << [current_user, :reporter] }
let(:current_params) do
{ scope: "repository:#{project.path_with_namespace}:push,pull" }
end
it_behaves_like 'a pullable'
end
context 'disallow guest to pull or push images' do
before { project.team << [current_user, :guest] }
let(:current_params) do
{ scope: "repository:#{project.path_with_namespace}:pull,push" }
end
it_behaves_like 'a forbidden'
end
end
context 'project authorization' do
let(:current_project) { create(:empty_project) }
context 'disallow to use offline_token' do
let(:current_params) do
{ offline_token: true }
end
it_behaves_like 'a forbidden'
end
context 'allow to pull and push images' do
let(:current_params) do
{ scope: "repository:#{current_project.path_with_namespace}:pull,push" }
end
it_behaves_like 'a pullable and pushable' do
let(:project) { current_project }
end
end
context 'for other projects' do
context 'when pulling' do
let(:current_params) do
{ scope: "repository:#{project.path_with_namespace}:pull" }
end
context 'allow for public' do
let(:project) { create(:empty_project, :public) }
it_behaves_like 'a pullable'
end
context 'disallow for private' do
let(:project) { create(:empty_project, :private) }
it_behaves_like 'a forbidden'
end
end
context 'when pushing' do
let(:current_params) do
{ scope: "repository:#{project.path_with_namespace}:push" }
end
context 'disallow for all' do
let(:project) { create(:empty_project, :public) }
it_behaves_like 'a forbidden'
end
end
end
end
context 'unauthorized' do
context 'disallow to use offline_token' do
let(:current_params) do
{ offline_token: true }
end
it_behaves_like 'a forbidden'
end
context 'for invalid scope' do
let(:current_params) do
{ scope: 'invalid:aa:bb' }
end
it_behaves_like 'a forbidden'
end
context 'for private project' do
let(:project) { create(:empty_project, :private) }
let(:current_params) do
{ scope: "repository:#{project.path_with_namespace}:pull" }
end
it_behaves_like 'a forbidden'
end
context 'for public project' do
let(:project) { create(:empty_project, :public) }
context 'when pulling and pushing' do
let(:current_params) do
{ scope: "repository:#{project.path_with_namespace}:pull,push" }
end
it_behaves_like 'a pullable'
end
context 'when pushing' do
let(:current_params) do
{ scope: "repository:#{project.path_with_namespace}:push" }
end
it_behaves_like 'a forbidden'
end
end
end
end