diff --git a/Gemfile b/Gemfile index 7a9e6fe18..0026eb4e3 100644 --- a/Gemfile +++ b/Gemfile @@ -90,3 +90,4 @@ gem 'terminal-table', '~> 3.0' # needed by rmt-server-pubcloud gem 'jwt', '~> 2.1' +gem 'base32' diff --git a/Gemfile.lock b/Gemfile.lock index 1dd3f56fc..424bb9232 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -34,6 +34,7 @@ GEM public_suffix (>= 2.0.2, < 6.0) ast (2.4.2) awesome_print (1.9.2) + base32 (0.3.4) base64 (0.2.0) bigdecimal (3.1.6) builder (3.2.4) @@ -332,6 +333,7 @@ DEPENDENCIES activerecord (~> 6.1.7) activesupport (~> 6.1.7) awesome_print + base32 byebug config (~> 3.0, >= 2.2.1) coveralls (~> 0.8.21) diff --git a/ci/rmt-configure b/ci/rmt-configure index 1208e2232..2bb5b6e5c 100755 --- a/ci/rmt-configure +++ b/ci/rmt-configure @@ -73,3 +73,40 @@ pushd "$INSTALL_DIR" popd groupend +# needed for Registry sidecar engine +group "create /etc/rmt/ssl/rmt-server.key" + echo "create /etc/rmt/ssl/rmt-server.key" + mkdir -p /etc/rmt/ssl/ + openssl genrsa -out /etc/rmt/ssl/rmt-key.key 2048 +groupend + +group "create /etc/rmt/access_policies.yml" + echo "create /etc/rmt/access_policies.yml" + mkdir -p /etc/rmt +cat > /etc/rmt/access_policies.yml << EOL +free: +- "*" +- bci/** +- caasp/** +- cap/** +- cap-beta/** +- cap-staging/** +- harbor/** +- harvester-beta/** +- ptf/** +- rancher/** +- registry/** +- scc/** +- ses/** +- sles12/** +- suse/* +- suse/backup/** +- suse/manager/4.3/** +- suse/sle-micro/** +- suse/sle-micro-iso/** +- suse/sle-micro-rancher/** +- suse/sles/** +- suse/vmdp/** +- trento/** +EOL +groupend diff --git a/config/application.rb b/config/application.rb index 581f9eac3..75fdd86bd 100644 --- a/config/application.rb +++ b/config/application.rb @@ -58,6 +58,19 @@ class Application < Rails::Application config.eager_load_paths << Rails.root.join('lib') config.eager_load_paths << Rails.root.join('app', 'validators') + # :nocov: + if defined?(Registry::Engine) && Rails.env.production? + # registry config needed + config.autoloader = :classic + config.registry_private_key = OpenSSL::PKey::RSA.new( + File.read('/etc/rmt/ssl/rmt-server.key') + ) + config.registry_public_key = config.registry_private_key.public_key + config.access_policies = '/etc/rmt/access_policies.yml' + # registry config needed end + end + # :nocov: + # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers # -- all .rb files in that directory are automatically loaded after loading diff --git a/config/environments/test.rb b/config/environments/test.rb index 8a312de06..38dcf5897 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -36,6 +36,11 @@ # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr + # for registry + config.access_policies = 'engines/registry/spec/data/access_policies.yml' + config.registry_private_key = OpenSSL::PKey::RSA.new(2048) + config.registry_public_key = config.registry_private_key.public_key + config.autoload_paths << Rails.root.join('engines/registry/spec/support/') # Raises error for missing translations. # config.action_view.raise_on_missing_translations = true end diff --git a/config/routes.rb b/config/routes.rb index 3e696e521..9cea050f2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -63,6 +63,12 @@ mount RegistrationSharing::Engine, at: '/api/regsharing' if defined?(RegistrationSharing::Engine) mount InstanceVerification::Engine, at: '/api/instance' if defined?(InstanceVerification::Engine) + if defined?(Registry::Engine) + mount Registry::Engine, at: '/api/registry' + + get '/v2/_catalog', to: 'registry/registry#catalog' + end + if defined?(SccSumaApi::Engine) mount SccSumaApi::Engine, at: '/api/scc' diff --git a/engines/registry/.gitignore b/engines/registry/.gitignore new file mode 100644 index 000000000..8619e09c5 --- /dev/null +++ b/engines/registry/.gitignore @@ -0,0 +1,3 @@ +.bundle/ +log/*.log +pkg/ diff --git a/engines/registry/README.md b/engines/registry/README.md new file mode 100644 index 000000000..c13bd037d --- /dev/null +++ b/engines/registry/README.md @@ -0,0 +1,3 @@ +# Registry +Provide an authentication end point for a container registry. +This supports access control based on system credentials to registry paths that are set to have access restrictions. diff --git a/engines/registry/app/controllers/registry/application_controller.rb b/engines/registry/app/controllers/registry/application_controller.rb new file mode 100644 index 000000000..ef77cef32 --- /dev/null +++ b/engines/registry/app/controllers/registry/application_controller.rb @@ -0,0 +1,4 @@ +module Registry + class ApplicationController < ActionController::Base + end +end diff --git a/engines/registry/app/controllers/registry/registry_controller.rb b/engines/registry/app/controllers/registry/registry_controller.rb new file mode 100644 index 000000000..0780ac098 --- /dev/null +++ b/engines/registry/app/controllers/registry/registry_controller.rb @@ -0,0 +1,103 @@ +module Registry + class RegistryController < Registry::ApplicationController + REGISTRY_SERVICE = 'SUSE Linux OCI Registry'.freeze + REGISTRY_API_VERSION = 'registry/2.0'.freeze + + before_action :set_requested_scopes, except: [ :catalog ] + before_action :basic_auth, except: [ :catalog ] + before_action :catalog_token_auth, only: [ :catalog ] + + # AuthZ handler + # AuthZ will validate which of the requested scope policies are fulfilled + # with the current login access and prepare the token to be sent back to the client + def authorize + token = AccessToken.new(@client&.account, params['service'], @requested_scopes.map { |s| s.granted(client: @client) }).token + render json: { token: token }, status: :ok + end + + # Catalog handler + # Returns a Distribution Registry HTTP API V2 - compatible repository catalog as defined in + # https://distribution.github.io/distribution/spec/api/#listing-repositories + def catalog + access_scope = AccessScope.parse('registry:catalog:*') + origin_url = request.protocol + request.host + repos = access_scope.allowed_paths(System.find_by(login: @client&.account), origin_url) + logger.debug("Returning #{repos.size} repos for client #{@client}") + + response.set_header('Docker-Distribution-Api-Version', REGISTRY_API_VERSION) + render json: { repositories: repos }, status: :ok + end + + private + + # Support multiple scopes. Podman & Docker handle this differently + # - Podman sends multiple querystrings with the same name. + # - Docker sends space-separated values in only one query string. + # This should normalize everything + def set_requested_scopes + raw_scopes = CGI.parse(request.env['QUERY_STRING']).fetch('scope', []) + .join(' ') + .split + .map(&:presence) + .compact + .map(&:downcase) + + @requested_scopes = [] + @requested_scopes = raw_scopes.map { |scope| AccessScope.parse(scope) } unless raw_scopes.empty? + + logger.info("Requested scopes: #{@requested_scopes.map(&:to_s)}") + end + + # AuthN handler + # https://docs.docker.com/registry/spec/auth/jwt/#getting-a-bearer-token + def basic_auth + # skip authentication if this is not a login request + return unless request.authorization + + authenticate_or_request_with_http_basic('SUSE Registry Authentication') do |login, password| + begin + @client = Registry::AuthenticatedClient.new(login, password) + rescue StandardError + logger.info _('Could not find system with login \"%{login}\" and password \"%{password}\"') % + { login: login, password: password } + error = ActionController::TranslatedError.new(N_('Please, re-authenticate')) + error.status = :unauthorized + render json: { error: error.message }.to_json, status: :unauthorized + end + + true + end + end + + def catalog_token_auth + authenticate_or_request_with_http_token(authorize_url, 'authentication required') do |token| + begin + @client = CatalogClient.new(token) + rescue JWT::DecodeError + logger.info _('Invalid token') + error = ActionController::TranslatedError.new(N_('Please, run cloudguestregistryauth')) + error.status = :unauthorized + render json: { error: error.message }.to_json, status: :unauthorized + return + end + + @client.authorized_for_catalog? + end + end + + # is called by authenticate_or_request_with_http_token when client provides no token + def request_http_token_authentication(realm = authorize_url, message = 'authentication required') + www_authenticate = [ + %(Bearer realm="#{realm.delete('"')}"), + %(service="#{REGISTRY_SERVICE.delete('"')}"), + %(scope="registry:catalog:*") + ] + + www_authenticate << %(error="insufficient_scope") if request.authorization + + headers['WWW-Authenticate'] = www_authenticate.join(',') + + render json: { errors: [ code: 'UNAUTHORIZED', details: nil, message: message] }, status: :unauthorized + end + end +end diff --git a/engines/registry/app/models/access_scope.rb b/engines/registry/app/models/access_scope.rb new file mode 100644 index 000000000..0d9b17062 --- /dev/null +++ b/engines/registry/app/models/access_scope.rb @@ -0,0 +1,107 @@ +require 'yaml' + +AUTHORIZED_ACTION = ['pull'].freeze +# this is analogous to auth.Access in golang code.' +class AccessScope + attr_accessor :type, + :class, + :namespace, + :image, + :actions + + # Parses a String into an authorization scope. + # + # scope - a String containing an authorization scope in the following format: + # `[()]:/:` + # `repository(special):name:pull` + # `registry:catalog:*` + # + # Returns a new Scope. + def self.parse(scope) + raise_on_invalid_scope(scope) + type, name, actions = scope.split(':') + _, type, klass = /(\w+)\(?(\w+)?\)?/.match(type).to_a + actions = actions.split(',') + new(type: type, klass: klass, name: name, actions: actions) + end + + def initialize(type:, name:, actions:, klass: nil) + @type = type + @klass = klass + @namespace, _, @image = name.rpartition('/').map(&:strip).map(&:presence) + @actions = actions + end + + def full_type + return "#{@type}(#{@klass})" if @klass.present? + + @type + end + + def to_s + "#{full_type}:#{name}:#{@actions.join(',')}" + end + + def granted(client: nil) + aa = authorized_actions(client) + Rails.logger.info "Granted actions for user '#{client&.account || ''}': #{aa}" + { + 'type' => @type, + 'class' => @klass, + 'name' => name, + 'actions' => aa + } + end + + def name + [namespace, image].map(&:presence).compact.join('/') + end + + def authorized_actions(client = nil) + if @namespace.nil? + @image == 'catalog' ? @actions : AUTHORIZED_ACTION + else + allowed_paths(client.systems.first) + if @allowed_paths.any? { |allowed_path| File.fnmatch(@namespace + '*', allowed_path) } + @actions & AUTHORIZED_ACTION + else + [] + end + end + end + + def self.raise_on_invalid_scope(scope) + # if nothing is passed, return + raise Registry::Exceptions::InvalidScope.new('Empty scope') if scope.blank? + # if scope is malformed, return + raise Registry::Exceptions::InvalidScope.new('Invalid scope format') unless scope.split(':').size == 3 + raise Registry::Exceptions::InvalidScope.new('Invalid scope format') unless %r{^[a-z0-9\-_/:*(),.]+$}i.match?(scope) + end + + def allowed_paths(system = nil, origin_url = nil) + repo_list = RegistryCatalogService.new.repos(reload: false, system: system, origin_url: origin_url) + access_policies_yml = YAML.safe_load( + File.read(Rails.application.config.access_policies) + ) + active_products = system.activations.includes(:product).pluck(:product_class) + + allowed_products = (active_products & access_policies_yml.keys) + allowed_glob_paths = access_policies_yml.values_at(*allowed_products).flatten + + @allowed_paths = parse_repos(repo_list, allowed_glob_paths) + end + + def parse_repos(repos, allowed_paths) + filtered_repos = [] + + allowed_paths.each do |allowed_path| + pattern = allowed_path.gsub(/(? jwt_kid }) + end + + private + + def claim + {}.tap do |hash| + hash['iss'] = 'RMT' # "matching issuer in registry auth token config" + hash['sub'] = @account + hash['aud'] = @service + hash['exp'] = Time.now.getlocal.to_i + (5 * 60) # expires at + hash['nbf'] = Time.now.getlocal.to_i # not before + hash['iat'] = Time.now.getlocal.to_i # issued at + hash['jti'] = Base64.urlsafe_encode64(SecureRandom.uuid, padding: false) + hash['access'] = @granted_scopes + Rails.logger.debug { "Returning token for claim: #{hash}" } + end + end + + # Returns the ID of the key which was to used to sign the token. + def jwt_kid + sha256 = Digest::SHA256.new + sha256.update(private_key.public_key.to_der) + payload = StringIO.new(sha256.digest).read(30) + Base32.encode(payload).chars.each_slice(4).with_object([]) do |slice, mem| + mem << slice.join + mem + end.join(':') + end + + def private_key + @private_key ||= Rails.application.config.registry_private_key + end +end diff --git a/engines/registry/app/models/catalog_client.rb b/engines/registry/app/models/catalog_client.rb new file mode 100644 index 000000000..5b01c56f8 --- /dev/null +++ b/engines/registry/app/models/catalog_client.rb @@ -0,0 +1,24 @@ +class CatalogClient + include RegistryClient + + def initialize(token) + payload = JWT.decode(token, public_key, true, { algorithm: 'RS256' }) + @account = payload.first['sub'] + @access = payload.first.fetch('access', []) + @auth_strategy = nil if @account.blank? + Rails.logger.info("Got token for '#{self}'") + end + + def authorized_for_catalog? + (@access.any? && + @access.first.fetch('type', '') == 'registry' && # TODO: better not only check the first 'access' + @access.first.fetch('name', '') == 'catalog' && + @access.first.fetch('actions', []) == ['*']) + end + + private + + def public_key + Rails.application.config.registry_private_key.public_key + end +end diff --git a/engines/registry/app/models/concerns/registry_client.rb b/engines/registry/app/models/concerns/registry_client.rb new file mode 100644 index 000000000..03658a8cc --- /dev/null +++ b/engines/registry/app/models/concerns/registry_client.rb @@ -0,0 +1,6 @@ +module RegistryClient + extend ActiveSupport::Concern + attr_reader :account + attr_reader :systems + +end diff --git a/engines/registry/app/models/registry/authenticated_client.rb b/engines/registry/app/models/registry/authenticated_client.rb new file mode 100644 index 000000000..9cb552163 --- /dev/null +++ b/engines/registry/app/models/registry/authenticated_client.rb @@ -0,0 +1,25 @@ +class Registry::AuthenticatedClient + include RegistryClient + + attr_reader :auth_strategy + + def initialize(login, password) + authenticate_by_system_credentials(login, password) + if @auth_strategy + Rails.logger.info("Authenticated '#{self}'") + else + raise Registry::Exceptions::InvalidCredentials.new(login: login) + end + end + + private + + def authenticate_by_system_credentials(login, password) + @systems = System.get_by_credentials(login, password) + if @systems.present? + @account = login + @auth_strategy = :system_credentials + end + @auth_strategy + end +end diff --git a/engines/registry/app/models/registry/exceptions.rb b/engines/registry/app/models/registry/exceptions.rb new file mode 100644 index 000000000..e5b913fee --- /dev/null +++ b/engines/registry/app/models/registry/exceptions.rb @@ -0,0 +1,20 @@ +module Registry::Exceptions + class InvalidScope < StandardError + attr_accessor :status + + def initialize(message = nil, status = 400) + @status = status + super(message) + end + end + + class InvalidCredentials < StandardError + attr_accessor :status + + def initialize(message: nil, status: 401, login: nil) + Rails.logger.warn "Invalid credentials provided for login '#{login}'" + @status = status + super(message) + end + end +end diff --git a/engines/registry/app/services/registry_catalog_service.rb b/engines/registry/app/services/registry_catalog_service.rb new file mode 100644 index 000000000..013dd61f0 --- /dev/null +++ b/engines/registry/app/services/registry_catalog_service.rb @@ -0,0 +1,67 @@ +require 'json' +require 'net/http' + +CATALOG_API_URL = 'http://127.0.0.1:5000/v2/_catalog'.freeze +AUTH_URL = 'api/registry/authorize'.freeze +CATALOG_SCOPE = 'registry:catalog:*'.freeze +SERVICE = 'SUSE Linux OCI Registry'.freeze + +class RegistryCatalogService + attr_accessor :catalog_api_url + + # We overwrite the public request /v2/_catalog with our endpoint + # RMT still needs to be able to get the unfiltered _catalog from the registry + # So we query CATALOG_API_URL (registry catalog) and return the fetched repositories + # n > 1000 crashes the request to CATALOG_API_URL + + def initialize(url = CATALOG_API_URL) + @catalog_api_url = url + end + + # be aware that this takes about 20-25 seconds to be finished if not in cache + def repos(reload: false, system: nil, origin_url: nil) + Rails.cache.fetch(@catalog_api_url, expires_in: 1.hour, force: reload) do + fetch_registry_repos(system, origin_url) + end + end + + private + + def fetch_registry_repos(system, origin_url) + Rails.logger.info('Fetch registry repos') + response = catalog_token(system, origin_url) + catalog_auth_token = JSON.parse(response.body).fetch('token', '') + response = all_repos(catalog_auth_token) + JSON.parse(response.body).fetch('repositories', []) + end + + def catalog_token(system, origin_url) + uri = URI.parse(URI.join(origin_url, AUTH_URL).to_s) + catalog_token_params = { + service: SERVICE, + account: system.login, + scope: CATALOG_SCOPE + } + uri.query = URI.encode_www_form(catalog_token_params) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + registry_request = Net::HTTP::Get.new(uri.to_s) + http.request(registry_request) + end + + def all_repos(auth_token) + uri = URI.parse(@catalog_api_url) + # n > 1000 crashes the request to CATALOG_API_URL + uri.query = URI.encode_www_form({ n: 1000 }) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = false + registry_request = Net::HTTP::Get.new(uri.to_s) + registry_request['Authorization'] = format("Bearer #{auth_token}") + response = nil + time = Benchmark.realtime do + response = http.request(registry_request) + end + Rails.logger.info("… took #{time}") + response + end +end diff --git a/engines/registry/bin/rails b/engines/registry/bin/rails new file mode 100755 index 000000000..261e6b752 --- /dev/null +++ b/engines/registry/bin/rails @@ -0,0 +1,13 @@ +#!/usr/bin/env ruby +# This command will automatically be run when you run "rails" with Rails gems +# installed from the root of your application. + +ENGINE_ROOT = File.expand_path('..', __dir__) +ENGINE_PATH = File.expand_path('../lib/registry/engine', __dir__) + +# Set up gems listed in the Gemfile. +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) + +require 'rails/all' +require 'rails/engine/commands' diff --git a/engines/registry/config/routes.rb b/engines/registry/config/routes.rb new file mode 100644 index 000000000..6f69094b2 --- /dev/null +++ b/engines/registry/config/routes.rb @@ -0,0 +1,4 @@ +Registry::Engine.routes.draw do + get 'authorize', to: 'registry#authorize' + get 'catalog', to: 'registry#catalog' +end diff --git a/engines/registry/lib/registry.rb b/engines/registry/lib/registry.rb new file mode 100644 index 000000000..108a2a63b --- /dev/null +++ b/engines/registry/lib/registry.rb @@ -0,0 +1,6 @@ +$LOAD_PATH.push File.expand_path(__dir__, '..') + +require 'registry/engine' + +module Registry +end diff --git a/engines/registry/lib/registry/engine.rb b/engines/registry/lib/registry/engine.rb new file mode 100644 index 000000000..98a0d39bf --- /dev/null +++ b/engines/registry/lib/registry/engine.rb @@ -0,0 +1,6 @@ +module Registry + class Engine < ::Rails::Engine + isolate_namespace Registry + config.generators.api_only = true + end +end diff --git a/engines/registry/lib/registry/version.rb b/engines/registry/lib/registry/version.rb new file mode 100644 index 000000000..c00c95825 --- /dev/null +++ b/engines/registry/lib/registry/version.rb @@ -0,0 +1,5 @@ +# :nocov: +module Registry + VERSION = '0.1.0'.freeze +end +# :nocov: diff --git a/engines/registry/lib/tasks/registry.rake b/engines/registry/lib/tasks/registry.rake new file mode 100644 index 000000000..d6eebe4bb --- /dev/null +++ b/engines/registry/lib/tasks/registry.rake @@ -0,0 +1,7 @@ +namespace :registry do + desc 'Refresh Repository Cache' + task refresh_cache: :environment do + repo_count = RegistryCatalogService.new.repos(reload: true).size + puts "Refresh done. Got #{repo_count} repos." + end +end diff --git a/engines/registry/spec/app/controllers/registry_controller_spec.rb b/engines/registry/spec/app/controllers/registry_controller_spec.rb new file mode 100644 index 000000000..e4e43b13f --- /dev/null +++ b/engines/registry/spec/app/controllers/registry_controller_spec.rb @@ -0,0 +1,109 @@ +module Registry + describe RegistryController, type: :request do + describe '#authenticate' do + context 'login request with invalid credentials' do + let(:auth_headers) { { 'Authorization' => ActionController::HttpAuthentication::Basic.encode_credentials('login', 'password') } } + + it 'succeeds with login + password from secrets' do + get('/api/registry/authorize', headers: auth_headers) + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'login request with valid credentials' do + let(:system) { create(:system) } + let(:auth_headers) { { 'Authorization' => ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) } } + + it 'succeeds with login + password from secrets' do + get('/api/registry/authorize', headers: auth_headers) + + expect(response).to have_http_status(:ok) + end + end + end + + describe '#catalog without access token' do + let(:system) { create(:system) } + let(:auth_headers) { { 'Authorization' => ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) } } + + it 'returns 401' do + get('/api/registry/catalog') + expect(response).to have_http_status(:unauthorized) + expect(response.header['WWW-Authenticate']).not_to include('error="insufficient_scope"') + end + + it 'with a token that has no access to catalog' do + get('/api/registry/authorize', params: { scope: '' }, headers: auth_headers) + + request.headers.merge({ 'HTTP_AUTHORIZATION' => "Bearer #{json_response[:token]}" }) + get('/api/registry/catalog', headers: auth_headers) + + expect(response.header['WWW-Authenticate']).to include('error="insufficient_scope"') + end + end + + describe '#catalog access' do + let(:system) { create(:system) } + let(:auth_headers) { { 'Authorization' => ActionController::HttpAuthentication::Basic.encode_credentials(system.login, system.password) } } + let(:auth_headers_token) { {} } + + let(:fake_response) { { repositories: repositories_returned } } + let(:repositories_returned) do + %w[repo repo.v2 level1/repo.v2 level1/level2 level1/level2/repo level1/level2/level.3 level1/level2/level.3/repo] + end + let(:authorize_url) { 'api/registry/authorize' } + let(:root_url) { 'smt-ec2.susecloud.net' } + let(:params_catalog) { "account=#{system.login}&scope=registry:catalog:*&service=SUSE%20Linux%20OCI%20Registry" } + let(:access_policy_content) { File.read('engines/registry/spec/data/access_policy_yaml.yml') } + let(:registry_conf) { { root_url: root_url } } + + + before do + stub_request(:get, "https://www.example.com:80/api/registry/authorize?account=#{system.login}&scope=registry:catalog:*&service=SUSE%20Linux%20OCI%20Registry") + .with( + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } +).to_return(status: 200, body: JSON.dump({ foo: 'foo' }), headers: {}) + + stub_request(:get, "#{RegistryCatalogService.new.catalog_api_url}?n=1000") + .to_return(body: JSON.dump(fake_response), status: 200, headers: { 'Content-type' => 'application/json' }) + end + + context 'with a valid token' do + it 'has catalog access' do + allow(File).to receive(:read).and_return(access_policy_content) + get( + '/api/registry/authorize', + params: { service: 'SUSE Linux OCI Registry', scope: 'registry:catalog:*' }, + headers: auth_headers + ) + + auth_headers_token['Authorization'] = format("Bearer #{json_response[:token]}") + get('/api/registry/catalog', headers: auth_headers_token) + + expect(response).to have_http_status(:ok) + end + end + + context 'when token is invalid' do + it 'raise an exception' do + get( + '/api/registry/authorize', + params: { service: 'SUSE Linux OCI Registry', scope: 'registry:catalog:*' }, + headers: auth_headers + ) + + auth_headers_token['Authorization'] = format('Bearer foo') + + get('/api/registry/catalog', headers: auth_headers_token) + + expect(response).to have_http_status(:unauthorized) + end + end + end + end +end diff --git a/engines/registry/spec/app/models/access_scope_spec.rb b/engines/registry/spec/app/models/access_scope_spec.rb new file mode 100644 index 000000000..1af1d75b1 --- /dev/null +++ b/engines/registry/spec/app/models/access_scope_spec.rb @@ -0,0 +1,229 @@ +require 'yaml' +require 'spec_helper' + +RSpec.describe AccessScope, type: :model do + subject { build(:registry_access_scope) } + + describe '.name' do + context 'without repository' do + subject(:build_call) { build(:registry_access_scope, namespace: nil, image: 'leap') } + + it 'returns the repo' do + expect(build_call.name).to eq('leap') + end + end + + context 'normal /' do + subject(:build_call) { build(:registry_access_scope, namespace: 'suse', image: 'leap') } + + it 'returns the repo' do + expect(build_call.name).to eq('suse/leap') + end + end + + context 'nested //' do + subject(:build_call) { build(:registry_access_scope, namespace: 'suse/test', image: 'leap') } + + it 'returns the repo' do + expect(build_call.name).to eq('suse/test/leap') + end + end + end + + describe '#parse' do + context 'without namespace' do + subject(:parse) { described_class.parse('repository:leap:pull') } + + it 'returns the scope' do + expect(parse.to_s).to eq('repository:leap:pull') + end + end + + context 'normal /' do + subject(:parse) { described_class.parse('repository:suse/leap:pull') } + + it 'returns the scope' do + expect(parse.to_s).to eq('repository:suse/leap:pull') + end + end + + context 'nested //' do + subject(:parse) { described_class.parse('repository:suse/leap/leap:pull') } + + it 'returns the scope' do + expect(parse.to_s).to eq('repository:suse/leap/leap:pull') + end + end + + context 'with multiple actions' do + subject(:parse) { described_class.parse('repository:suse/leap/leap:pull,push') } + + it 'returns the scope' do + expect(parse.to_s).to eq('repository:suse/leap/leap:pull,push') + end + end + + context 'with class' do + subject(:parse) { described_class.parse('repository(class):suse/leap/leap:pull,push') } + + it 'returns the scope' do + expect(parse.to_s).to eq('repository(class):suse/leap/leap:pull,push') + end + end + + context 'with different type' do + subject(:parse) { described_class.parse('registry:catalog:*') } + + it 'returns the scope' do + expect(parse.to_s).to eq('registry:catalog:*') + end + end + + context 'with valid string' do + it 'allows dots in scope' do + scope = 'repository:rancher/elemental/elemental-teal-channel/5.3:pull' + expect(described_class.parse(scope).to_s).to eq(scope) + end + end + + context 'with invalid string' do + it 'raises on empty scope' do + expect { described_class.parse('') }.to raise_error(Registry::Exceptions::InvalidScope, /Empty scope/) + end + + it 'raises on nil scope' do + expect { described_class.parse(nil) }.to raise_error(Registry::Exceptions::InvalidScope, /Empty scope/) + end + + it 'raises on more than 2 ":"' do + expect { described_class.parse('repo:test:tag:pull') }.to raise_error(Registry::Exceptions::InvalidScope, /Invalid scope format/) + end + + it 'raises on invalid characters' do + expect { described_class.parse('repo$:test:pull') }.to raise_error(Registry::Exceptions::InvalidScope, /Invalid scope format/) + end + end + end + + describe ".granted['actions']" do + let(:product1) do + product = FactoryBot.create(:product, :with_mirrored_repositories) + product.repositories.where(enabled: false).update(mirroring_enabled: false) + product + end + let(:product2) do + product = FactoryBot.create(:product, :with_mirrored_repositories) + product.repositories.where(enabled: false).update(mirroring_enabled: false) + product + end + let(:system) do + system = FactoryBot.create(:system) + system.activations << [ + FactoryBot.create(:activation, system: system, service: product1.service), + FactoryBot.create(:activation, system: system, service: product2.service) + ] + system + end + let(:client) do + double( # rubocop:disable RSpec/VerifiedDoubles + :registryclient, + account: 'foo', + systems: [system] + ) + end + + context 'when namespace is null' do + subject(:access_scope) { described_class.new(type: 'a', name: 'b', actions: 'c') } + # let(:scope) { build(:registry_access_scope, namespace: 'suse', image: 'leap') } + + it 'returns default auth actions' do + possible_access = access_scope.granted(client: client) + + expect(possible_access).to eq({ 'type' => 'a', 'actions' => ['pull'], 'class' => nil, 'name' => 'b' }) + end + end + + context 'when namespace is not null' do + let(:access_policy_content) { File.read('engines/registry/spec/data/access_policy_yaml.yml') } + + context 'when action is allowed' do + subject(:access_scope) do + described_class.new( + type: 'a', + name: 'suse/sles/*', + actions: ['pull'] + ) + end + + it 'returns default auth actions (no free repos included)' do + yaml_string = access_policy_content + data = YAML.safe_load yaml_string + data[product1.product_class] = 'suse/**' + File.write('engines/registry/spec/data/access_policy_yaml.yml', YAML.dump(data)) + allow_any_instance_of(RegistryCatalogService).to receive(:repos).and_return(['suse/sles/super_repo']) + allow(File).to receive(:read).and_return(access_policy_content) + possible_access = access_scope.granted(client: client) + + expect(possible_access).to eq( + { + 'type' => 'a', + 'actions' => ['pull'], + 'class' => nil, + 'name' => 'suse/sles/*' + } + ) + end + end + + context 'when action is not allowed' do + subject(:access_scope) do + described_class.new( + type: 'a', + name: 'suse/sles/*', + actions: ['push'] + ) + end + + it 'returns empty auth actions' do + allow_any_instance_of(RegistryCatalogService).to receive(:repos).and_return(['suse/sles/super_repo']) + allow(File).to receive(:read).and_return(access_policy_content) + possible_access = access_scope.granted(client: client) + + expect(possible_access).to eq( + { + 'type' => 'a', + 'actions' => [], + 'class' => nil, + 'name' => 'suse/sles/*' + } + ) + end + end + + context 'when repo name is not allowed' do + subject(:access_scope) do + described_class.new( + type: 'a', + name: 'super_expensive/suse/sles/*', + actions: ['pull'] + ) + end + + it 'returns empty auth actions' do + allow_any_instance_of(RegistryCatalogService).to receive(:repos).and_return(['suse/sles/super_repo']) + allow(File).to receive(:read).and_return(access_policy_content) + possible_access = access_scope.granted(client: client) + + expect(possible_access).to eq( + { + 'type' => 'a', + 'actions' => [], + 'class' => nil, + 'name' => 'super_expensive/suse/sles/*' + } + ) + end + end + end + end +end diff --git a/engines/registry/spec/app/models/registry/authenticated_client_spec.rb b/engines/registry/spec/app/models/registry/authenticated_client_spec.rb new file mode 100644 index 000000000..c216c69de --- /dev/null +++ b/engines/registry/spec/app/models/registry/authenticated_client_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Registry::AuthenticatedClient do + describe '.new' do + context 'with system credentials' do + let(:system) { create(:system) } + + context 'with valid credentials' do + subject(:client) { described_class.new(system.login, system.password) } + + it 'returns the auth strategy' do + expect(client.systems).to eq([system]) + expect(client.auth_strategy).to eq(:system_credentials) + end + end + + context 'with invalid password' do + subject(:client) { described_class.new(system.login, 'wrong') } + + it 'raises' do + expect { client }.to raise_error(Registry::Exceptions::InvalidCredentials) + end + end + end + end +end diff --git a/engines/registry/spec/app/services/registry_catalog_service_spec.rb b/engines/registry/spec/app/services/registry_catalog_service_spec.rb new file mode 100644 index 000000000..6a1a96672 --- /dev/null +++ b/engines/registry/spec/app/services/registry_catalog_service_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe RegistryCatalogService do + subject(:registry) { described_class.new } + + let(:system) { create(:system) } + let(:root_url) { 'smt-ec2.susecloud.net' } + let(:auth_url) { "https://#{root_url}/api/registry/authorize" } + let(:params) { "account=#{system.login}&scope=registry:catalog:*&service=SUSE%20Linux%20OCI%20Registry" } + let(:response) { { repositories: repositories_returned } } + let(:repositories_returned) do + %w[repo repo.v2 level1/repo.v2 level1/level2 level1/level2/repo level1/level2/level.3 level1/level2/level.3/repo] + end + let(:registry_conf) { { root_url: root_url } } + + before do + stub_request(:get, "#{auth_url}?#{params}").with( + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } +).to_return( + status: 200, body: JSON.dump({ token: 'foo_token' }), headers: {} + ) + + stub_request(:get, "#{registry.catalog_api_url}?n=1000") + .to_return(body: JSON.dump(response), status: 200, headers: { 'Content-type' => 'application/json' }) + end + + it 'lists all repos' do + allow(System).to receive(:where).and_return([system]) + expect(registry.repos(system: system, origin_url: "https://#{root_url}").length).to eq repositories_returned.size + end +end diff --git a/engines/registry/spec/data/access_policy_yaml.yml b/engines/registry/spec/data/access_policy_yaml.yml new file mode 100644 index 000000000..5d6f778c3 --- /dev/null +++ b/engines/registry/spec/data/access_policy_yaml.yml @@ -0,0 +1,26 @@ +--- +free: +- "*" +- bci/** +- caasp/** +- cap/** +- cap-beta/** +- cap-staging/** +- harbor/** +- harvester-beta/** +- ptf/** +- rancher/** +- registry/** +- scc/** +- ses/** +- sles12/** +- suse/* +- suse/backup/** +- suse/manager/4.3/** +- suse/sle-micro/** +- suse/sle-micro-iso/** +- suse/sle-micro-rancher/** +- suse/sles/** +- suse/vmdp/** +- trento/** +72AAA: suse/** diff --git a/engines/registry/spec/factories/access_scope.rb b/engines/registry/spec/factories/access_scope.rb new file mode 100644 index 000000000..f055d96ed --- /dev/null +++ b/engines/registry/spec/factories/access_scope.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :registry_access_scope, class: 'AccessScope' do + type { 'repository' } + namespace { 'suse' } + image { 'sles:15.4' } + actions { ['pull'] } + + initialize_with { new(type: type, name: "#{namespace}/#{image}", actions: actions) } + end +end diff --git a/package/files/nginx-pubcloud/nginx-https.conf b/package/files/nginx-pubcloud/nginx-https.conf index 07cae2e82..37eb5148c 100644 --- a/package/files/nginx-pubcloud/nginx-https.conf +++ b/package/files/nginx-pubcloud/nginx-https.conf @@ -2,8 +2,21 @@ upstream rmt { server 127.0.0.1:4224; } +upstream registry { + server 127.0.0.1:5000; +} + +## Set a variable to help us decide if we need to add the +## 'Docker-Distribution-Api-Version' header. +## The registry always sets this header. +## In the case of nginx performing auth, the header is unset +## since nginx is auth-ing before proxying. +map $upstream_http_docker_distribution_api_version $docker_distribution_api_version { + '' 'registry/2.0'; +} + server { - listen 443 ssl; + listen 443 default_server ssl; listen [::]:443 default_server ssl; server_name rmt; @@ -50,6 +63,10 @@ server { try_files $uri @rmt_app; } + location /v2 { + try_files $uri @rmt_app; + } + location @rmt_app { proxy_pass http://rmt; proxy_redirect off; @@ -72,3 +89,69 @@ server { alias /etc/rmt/ssl/rmt-ca.crt; } } + +server { + listen 443 ssl; + listen [::]:443 ssl; + server_name ~^registry; + + access_log /var/log/nginx/registry_https_access.log; + error_log /var/log/nginx/registry_https_error.log; + root /var/lib/docker-registry; + + ssl_certificate /etc/rmt/ssl/susecloud-registry-ec2.crt; + ssl_certificate_key /etc/rmt/ssl/susecloud-registry-ec2.key; + + ssl_protocols TLSv1.2 TLSv1.3; + + # disable any limits to avoid HTTP 413 for large image uploads + client_max_body_size 0; + + # required to avoid HTTP 411: see Issue #1486 (https://github.com/moby/moby/issues/1486) + chunked_transfer_encoding on; + + location /v2/_catalog { + # redirect to the catalog endpoint + proxy_pass http://rmt; + proxy_redirect off; + proxy_read_timeout 600; + + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Ssl on; + proxy_set_header X-Real-IP $remote_addr; + } + + location /api/registry/authorize { + # redirect to the catalog endpoint + proxy_pass http://rmt; + proxy_redirect off; + proxy_read_timeout 600; + + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Ssl on; + proxy_set_header X-Real-IP $remote_addr; + } + + location /v2/ { + # Do not allow connections from docker 1.5 and earlier + # docker pre-1.6.0 did not properly set the user agent on ping, catch "Go *" user agents + if ($http_user_agent ~ "^(docker\/1\.(3|4|5(?!\.[0-9]-dev))|Go ).*$" ) { + return 404; + } + + ## If $docker_distribution_api_version is empty, the header is not added. + ## See the map directive above where this variable is defined. + add_header 'Docker-Distribution-Api-Version' $docker_distribution_api_version always; + + proxy_pass http://registry; + proxy_set_header Host $http_host; # required for docker client's sake + proxy_set_header X-Real-IP $remote_addr; # pass on real client's IP + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 900; + } +}