Skip to content
This repository has been archived by the owner on Apr 17, 2023. It is now read-only.

Fixed the registry client #259

Merged
merged 12 commits into from
Aug 11, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ AllCops:
- db/migrate/*
- bin/*
- vendor/**/*

- tmp/**/*
1 change: 0 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ gem "gravatar_image_tag"
gem "rails-observers"
gem "public_activity"
gem "active_record_union"
gem "rotp"
gem "mysql2"
gem "search_cop"

Expand Down
2 changes: 0 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,6 @@ GEM
rake (10.4.2)
responders (2.1.0)
railties (>= 4.2.0, < 5)
rotp (2.1.1)
rspec-core (3.3.0)
rspec-support (~> 3.3.0)
rspec-expectations (3.3.0)
Expand Down Expand Up @@ -344,7 +343,6 @@ DEPENDENCIES
rack-mini-profiler
rails (~> 4.2.2)
rails-observers
rotp
rspec-rails
rubocop
sass-rails (>= 3.2)
Expand Down
1 change: 1 addition & 0 deletions Vagrantfile
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ bundle config build.nokogiri --use-system-libraries
bundle install --retry=3
bundle exec rake db:create
bundle exec rake db:migrate
bundle exec rake db:seed

sudo gem install passenger -v 5.0.7
passenger-install-apache2-module.ruby2.1 -a
Expand Down
1 change: 1 addition & 0 deletions app/controllers/admin/dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ def index
@recent_activities = PublicActivity::Activity
.order("created_at DESC")
.limit(20)
@portus_exists = User.where(username: "portus").any?
end
end
20 changes: 1 addition & 19 deletions app/controllers/api/base_controller.rb
Original file line number Diff line number Diff line change
@@ -1,37 +1,19 @@
class Api::BaseController < ActionController::Base
class ScopeNotHandled < StandardError; end
class RegistryNotHandled < StandardError; end
class WrongPortusOTP < StandardError; end

include Pundit

respond_to :json

rescue_from Namespace::AuthScope::ResourceIsNotFound, with: :deny_access
rescue_from Pundit::NotAuthorizedError, with: :deny_access
rescue_from ScopeNotHandled, with: :deny_access
rescue_from RegistryNotHandled, with: :deny_access
rescue_from WrongPortusOTP, with: :deny_access
rescue_from Portus::AuthScope::ResourceNotFound, with: :deny_access

protected

def deny_access
head :unauthorized
end

def scope_handler(registry, scope_string)
type = scope_string.split(":", 3)[0]

case type
when "repository"
auth_scope = Namespace::AuthScope.new(registry, scope_string)
else
logger.error "Scope not handled: #{type}"
raise ScopeNotHandled
end

scopes = scope_string.split(":", 3)[2].split(",")

[auth_scope, scopes]
end
end
46 changes: 31 additions & 15 deletions app/controllers/api/v2/tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
# TokensController is used to deliver the token that the docker client should
# use in order to perform operation into the registry. This is the last step in
# the authentication process for Portus' point of view.
class Api::V2::TokensController < Api::BaseController
before_action :authenticate

def authenticate
if params["account"] == "portus"
totp = ROTP::TOTP.new(Rails.application.config.otp_secret)

authenticate_with_http_basic do |u, p|
raise WrongPortusOTP if u != "portus" || p != totp.now
end
else
authenticate_user!
end
end
before_action :authenticate_user!

# Returns the token that the docker client should use in order to perform
# operation into the private registry.
def show
registry = Registry.find_by(hostname: params["service"])
raise RegistryNotHandled if registry.nil?
Expand All @@ -31,11 +24,15 @@ def show

private

# If there was a scope specified in the request parameters, try to authorize
# the given scopes. That is, it "filters" the scopes that can be requested
# depending of the issuer of the request and its permissions.
def authorize_scopes(registry)
# The 'portus' user can do anything
return unless params[:scope] && params["account"] != "portus"
return unless params[:scope]

auth_scope, scopes = scope_handler(registry, params[:scope])
raise Pundit::NotAuthorizedError, "No scopes to handle" if scopes.empty?

scopes.each do |scope|
begin
authorize auth_scope.resource, "#{scope}?".to_sym
Expand All @@ -47,4 +44,23 @@ def authorize_scopes(registry)

auth_scope
end

# From the given scope string, try to fetch a scope handler class for it.
# Scope handlers are defined in "app/models/*/auth_scope.rb" files.
def scope_handler(registry, scope_string)
str = scope_string.split(":", 3)
raise ScopeNotHandled, "Wrong format for scope string" if str.length != 3

case str[0]
when "repository"
auth_scope = Namespace::AuthScope.new(registry, scope_string)
when "registry"
auth_scope = Registry::AuthScope.new(registry, scope_string)
else
logger.error "Scope not handled: #{str[0]}"
raise ScopeNotHandled
end

[auth_scope, auth_scope.scopes]
end
end
7 changes: 5 additions & 2 deletions app/controllers/auth/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,12 @@ def destroy
end

def check_admin
@admin = User.exists?(admin: true)
@admin = User.admins.any?
end

def configure_sign_up_params
devise_parameter_sanitizer.for(:sign_up) << :email
return if User.exists?(admin: true)
return if User.admins.any?
devise_parameter_sanitizer.for(:sign_up) << :admin
end

Expand All @@ -99,6 +99,9 @@ def password_update?
# 1. A user can disable himself unless it's the last admin on the system.
# 2. The admin user is the only one that can disable other users.
def can_disable?(user)
# The "portus" user can never be disabled.
return false if user.username == "portus"

if current_user == user
# An admin cannot disable himself if he's the only admin in the system.
current_user.admin? && User.admins.count == 1
Expand Down
33 changes: 10 additions & 23 deletions app/models/namespace/auth_scope.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
class Namespace::AuthScope
class ResourceIsNotFound < StandardError; end

# Namespace::AuthScope parses the scope string for the "namespace" type.
class Namespace::AuthScope < Portus::AuthScope
attr_accessor :resource, :actions, :resource_type, :resource_name

def initialize(registry, scope_string)
@scope_string = scope_string
@registry = registry
parse_scope_string!
end

def resource
if @namespace_name.blank?
found_resource = @registry.namespaces.find_by(global: true)
Expand All @@ -17,26 +10,20 @@ def resource
end

if found_resource.nil?
Rails.logger.warn "Namespace::AuthScope - Cannot find namespace with name #{@namespace_name}"
raise ResourceIsNotFound
Rails.logger.warn "Cannot find namespace with name #{@namespace_name}"
raise ResourceNotFound
end
found_resource
end

private
protected

# Re-implemented from Portus::AuthScope to deal with the name of the
# namespace.
def parse_scope_string!
@resource_type = @scope_string.split(":")[0]
@resource_name = @scope_string.split(":")[1]
@namespace_name = requested_resource_namespace_name
@actions = requested_actions
end

def requested_resource_namespace_name
@resource_name.split("/").first if @resource_name.include?("/")
end
super

def requested_actions
@scope_string.split(":")[2].split(",")
return unless @resource_name.include?("/")
@namespace_name = @resource_name.split("/").first
end
end
24 changes: 24 additions & 0 deletions app/models/registry/auth_scope.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Registry::AuthScope parses the scope string so it can be used afterwards for
# the "registry" type.
class Registry::AuthScope < Portus::AuthScope
def resource
reg = Registry.find_by(hostname: @registry.hostname)
if reg.nil?
Rails.logger.warn "Could not find registry #{@registry.hostname}"
raise ResourceNotFound
end
reg
end

def scopes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you provide more details about the two scopes, like when they are used? This is not so clear right now as it is for the namespace scope (where pull and push are pretty obvious). The goal is to avoid someone else to dig into the distribution code later on ;)

catalog? ? ["all"] : []
end

private

# Returns true if the given scope string corresponds to the /v2/_catalog
# endpoint.
def catalog?
@resource_name == "catalog" && @actions[0] == "*"
end
end
95 changes: 84 additions & 11 deletions app/models/registry_client.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
# RegistryClient is a a layer between Portus and the Registry. Given a set of
# credentials, it's able to call to any endpoint in the registry API. Moreover,
# it also implements some handy methods on top of some of these endpoints (e.g.
# the `manifest` method for the Manifest API endpoints).
class RegistryClient
# As specified in the token specification of distribution, the client will
# get a 401 on the first attempt of logging in, but in there should be the
# "WWW-Authenticate" header. This exception will be raised when there's no
# authentication token bearer.
class NoBearerRealmException < RuntimeError; end

# Raised when the authorization token could not be fetched.
class AuthorizationError < RuntimeError; end
class ManifestNotFoundError < RuntimeError; end

# Used when a resource was not found for the given endpoint.
class NotFoundError < RuntimeError; end

# Raised if this client does not have the credentials to perform an API call.
class CredentialsMissingError < RuntimeError; end

def initialize(host, use_ssl = true, username = nil, password = nil)
Expand All @@ -12,41 +26,82 @@ def initialize(host, use_ssl = true, username = nil, password = nil)
@password = password
end

def credentials?
@username && @password
end

# Retrieves the manifest for the required repository:tag. If everything goes
# well, it will return a parsed response from the registry, otherwise it will
# raise either ManifestNotFoundError or a RuntimeError.
def manifest(repository, tag = "latest")
res = get_request("#{repository}/manifests/#{tag}")
if res.code.to_i == 200
JSON.parse(res.body)
elsif res.code.to_i == 404
raise ManifestNotFoundError, "Cannot find manifest for #{repository}:#{tag}"
raise NotFoundError, "Cannot find manifest for #{repository}:#{tag}"
else
raise "Something went wrong while fetching manifest for #{repository}:#{tag}:" \
"[#{res.code}] - #{res.body}"
raise "Something went wrong while fetching manifest for " \
"#{repository}:#{tag}:[#{res.code}] - #{res.body}"
end
end

# Fetches all the repositories available in the registry, with all their
# corresponding tags. If something goes wrong while fetching the repos from
# the catalog (e.g. authorization error), it will raise an exception.
#
# Returns an array of hashes which contain two keys:
# - name: a string containing the name of the repository.
# - tags: an array containing the available tags for the repository.
def catalog
res = get_request("_catalog")
if res.code.to_i == 200
catalog = JSON.parse(res.body)
add_tags(catalog["repositories"])
elsif res.code.to_i == 404
raise NotFoundError, "Could not find the catalog endpoint!"
else
raise "Something went wrong while fetching the catalog " \
"Response: [#{res.code}] - #{res.body}"
end
end

# This is the general method to perform a GET request to an endpoint provided
# by the registry. The first parameter is the URI of the endpoint itself. The
# `request_auth_token` parameter means that if this method gets a 401 when
# calling the given path, it should get an authorization token automatically
# and try again.
def get_request(path, request_auth_token = true)
uri = URI.join(@base_url, path)
req = Net::HTTP::Get.new(uri)

# This only happens if the auth token has already been set by a previous
# call.
req["Authorization"] = "Bearer #{@token}" if @token

res = get_response_token(uri, req)
if res.code.to_i == 401
# Note that request_auth_token will raise an exception on error.
# This can mean that this is the first time that the client is calling
# the registry API, and that, therefore, it might need to request the
# authorization token first.
if request_auth_token
# Note that request_auth_token will raise an exception on error.
request_auth_token(res)

# Recursive call, but this time we make sure that we don't enter here
# again. If this call fails, then there's something *really* wrong with
# the given credentials.
return get_request(path, false)
end
else
res
end
res
end

private

# Returns true if this client has the credentials set.
def credentials?
@username && @password
end

# This method should be called after getting a 401. In this case, the
# registry has sent the proper "WWW-Authenticate" header value that will
# allow us the request a new authorization token for this client.
def request_auth_token(unhauthorized_response)
bearer_realm, query = parse_unhauthorized_response(unhauthorized_response)

Expand All @@ -64,6 +119,8 @@ def request_auth_token(unhauthorized_response)
end
end

# For the given 401 response, try to extract the token and the parameters
# that this client should use in order to request an authorization token.
def parse_unhauthorized_response(res)
auth_args = res.to_hash["www-authenticate"].first.split(",").each_with_object({}) do |i, h|
key, val = i.split("=")
Expand Down Expand Up @@ -96,4 +153,20 @@ def get_response_token(uri, req)
http.request(req)
end
end

# Adds the available tags for each of the given repositories. If there is a
# problem while fetching a repository's tag, it will return an empty array.
# Otherwise it will return an array with the results as specified in the
# documentation of the `catalog` method.
def add_tags(repositories)
return [] if repositories.nil?

result = []
repositories.each do |repo|
res = get_request("#{repo}/tags/list")
return [] if res.code.to_i != 200
result << JSON.parse(res.body)
end
result
end
end
Loading