From 373c9ece6072e1752633e686f4042b4651095914 Mon Sep 17 00:00:00 2001 From: Jesus Bermudez Velazquez Date: Tue, 6 Feb 2024 16:07:28 +0000 Subject: [PATCH] Add registry engine Add registry engine Add login support: auth based on SCC credentials -> username + password --- config/routes.rb | 1 + engines/registry/.gitignore | 6 ++ engines/registry/README.md | 28 ++++++ .../registry/application_controller.rb | 4 + .../registry/registry_controller.rb | 92 +++++++++++++++++++ .../app/models/registry/access_scope.rb | 83 +++++++++++++++++ .../app/models/registry/access_token.rb | 46 ++++++++++ .../app/models/registry/application_record.rb | 5 + .../models/registry/authenticated_client.rb | 28 ++++++ .../app/models/registry/catalog_client.rb | 75 +++++++++++++++ engines/registry/bin/rails | 13 +++ engines/registry/config/routes.rb | 2 + engines/registry/lib/registry.rb | 6 ++ engines/registry/lib/registry/engine.rb | 6 ++ engines/registry/lib/registry/version.rb | 3 + 15 files changed, 398 insertions(+) create mode 100644 engines/registry/.gitignore create mode 100644 engines/registry/README.md create mode 100644 engines/registry/app/controllers/registry/application_controller.rb create mode 100644 engines/registry/app/controllers/registry/registry_controller.rb create mode 100644 engines/registry/app/models/registry/access_scope.rb create mode 100644 engines/registry/app/models/registry/access_token.rb create mode 100644 engines/registry/app/models/registry/application_record.rb create mode 100644 engines/registry/app/models/registry/authenticated_client.rb create mode 100644 engines/registry/app/models/registry/catalog_client.rb create mode 100755 engines/registry/bin/rails create mode 100644 engines/registry/config/routes.rb create mode 100644 engines/registry/lib/registry.rb create mode 100644 engines/registry/lib/registry/engine.rb create mode 100644 engines/registry/lib/registry/version.rb diff --git a/config/routes.rb b/config/routes.rb index 3e696e521..35ea93a4f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -62,6 +62,7 @@ mount StrictAuthentication::Engine, at: '/api/auth' if defined?(StrictAuthentication::Engine) mount RegistrationSharing::Engine, at: '/api/regsharing' if defined?(RegistrationSharing::Engine) mount InstanceVerification::Engine, at: '/api/instance' if defined?(InstanceVerification::Engine) + mount Registry::Engine, at: '/api/registry' if defined?(Registry::Engine) 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..6611418ca --- /dev/null +++ b/engines/registry/.gitignore @@ -0,0 +1,6 @@ +/.bundle/ +/doc/ +/log/*.log +/pkg/ +/tmp/ +.byebug_history diff --git a/engines/registry/README.md b/engines/registry/README.md new file mode 100644 index 000000000..824840170 --- /dev/null +++ b/engines/registry/README.md @@ -0,0 +1,28 @@ +# Registry +Short description and motivation. + +## Usage +How to use my plugin. + +## Installation +Add this line to your application's Gemfile: + +```ruby +gem 'registry' +``` + +And then execute: +```bash +$ bundle +``` + +Or install it yourself as: +```bash +$ gem install registry +``` + +## Contributing +Contribution directions go here. + +## License +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 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..8e0eeac82 --- /dev/null +++ b/engines/registry/app/controllers/registry/application_controller.rb @@ -0,0 +1,4 @@ +module Registry + class ApplicationController < ActionController::API + 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..92d942235 --- /dev/null +++ b/engines/registry/app/controllers/registry/registry_controller.rb @@ -0,0 +1,92 @@ +module Registry + class RegistryController < ::ApplicationController + REGISTRY_SERVICE = 'SUSE Linux Docker 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 ] + + rescue_from Exception, with: :handle_exceptions + + # 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 = Registry::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 + repos = @client.matching_policies.repositories(account_numbers: @client.account_numbers) + 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 = raw_scopes.filter_map do |scope| + Registry::AccessScope.parse(scope) + end + 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 + respond_with_error(message: 'Invalid credentials', status: :unauthorized) and return + end + + true + end + end + + def catalog_token_auth + authenticate_or_request_with_http_token(authorize_api_registry_url, 'authentication required') do |token| + begin + @client = Registry::CatalogClient.new(token) + rescue JWT::DecodeError + respond_with_error(message: 'Invalid token', status: :unauthorized) and 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_api_registry_url, message = 'authentication required') + headers['WWW-Authenticate'] = [ + %(Bearer realm="#{realm.delete('"')}"), + %(service="#{REGISTRY_SERVICE.delete('"')}"), + %(scope="registry:catalog:*"), + %(error="insufficient_scope") + ].join(',') + + render json: { errors: [ code: 'UNAUTHORIZED', details: nil, message: message] }, status: :unauthorized + end + end +end diff --git a/engines/registry/app/models/registry/access_scope.rb b/engines/registry/app/models/registry/access_scope.rb new file mode 100644 index 000000000..ecfb86839 --- /dev/null +++ b/engines/registry/app/models/registry/access_scope.rb @@ -0,0 +1,83 @@ +# this is analogous to auth.Access in golang code. +class Registry::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 + + # there can be multiple policies matching the same scope, for example one for admins + # and one for customers + def policies + unless @policies + @policies = Registry::AccessPolicy.get_by_scope(self) + + if @policies.present? + Rails.logger.info "Matched AccessPolicies for '#{self}': '#{@policies.map(&:to_s)}'" + else + Rails.logger.info "No AccessPolicy found for '#{self}'" + end + end + @policies + end + + def authorized_actions(client = nil) + @actions.intersection(policies.map { |p| p.authorized_actions(client: client) }.flatten.uniq) + 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 +end diff --git a/engines/registry/app/models/registry/access_token.rb b/engines/registry/app/models/registry/access_token.rb new file mode 100644 index 000000000..9b00ad1fb --- /dev/null +++ b/engines/registry/app/models/registry/access_token.rb @@ -0,0 +1,46 @@ +require 'base32' + +# Following Docker distribution token auth specs, +# see docs here: https://github.com/distribution/distribution/blob/main/docs/spec/auth/token.md +class Registry::AccessToken + def initialize(account, service, granted_scopes) + @account = account + @service = service # "SUSE Linux Docker Registry" + @granted_scopes = granted_scopes + end + + def token + JWT.encode(claim, private_key, 'RS256', { 'kid' => jwt_kid }) + end + + private + + def claim + {}.tap do |hash| + hash['iss'] = 'registry.suse.com/auth' # "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 ||= OpenSSL::PKey::RSA.new(Registry.config[:key]) + end +end diff --git a/engines/registry/app/models/registry/application_record.rb b/engines/registry/app/models/registry/application_record.rb new file mode 100644 index 000000000..42c89fbd7 --- /dev/null +++ b/engines/registry/app/models/registry/application_record.rb @@ -0,0 +1,5 @@ +module Registry + class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true + end +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..758c45520 --- /dev/null +++ b/engines/registry/app/models/registry/authenticated_client.rb @@ -0,0 +1,28 @@ +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 + + def anonymous? + false + 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/catalog_client.rb b/engines/registry/app/models/registry/catalog_client.rb new file mode 100644 index 000000000..02cd4e443 --- /dev/null +++ b/engines/registry/app/models/registry/catalog_client.rb @@ -0,0 +1,75 @@ +class Registry::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 = :anonymous if @account.blank? + Rails.logger.info("Got token for '#{self}'") + end + + def auth_strategy + return @auth_strategy if @auth_strategy + + @auth_strategy = :anonymous unless secrets_credentials? || organization_credentials? || systems_credentials? || regcode? + @auth_strategy + 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 + + def anonymous? + auth_strategy == :anonymous + end + + def secrets_credentials? + return @auth_strategy == :secrets_credentials if @auth_strategy + + if registry_credentials.any? { |c| c[:login] == @account } + @auth_strategy = :secrets_credentials + true + end + end + + def organization_credentials? + return @auth_strategy == :organization_credentials if @auth_strategy + + org_credential = OrganizationCredential.find_by(username: @account) + if org_credential + @organization = org_credential.organization + @auth_strategy = :organization_credentials + true + end + end + + def systems_credentials? + return @auth_strategy == :system_credentials if @auth_strategy + + @systems = System.where(login: @account) + if @systems.present? + @auth_strategy = :system_credentials + true + end + end + + def regcode? + return @auth_strategy == :regcode if @auth_strategy + + @subscription = Subscription.find_by(regcode: @account) + if @subscription.present? + @auth_strategy = :regcode + true + end + end + + private + + def public_key + OpenSSL::X509::Certificate.new(CREDENTIALS.dig(:registry, :certificate) || '').public_key + end +end diff --git a/engines/registry/bin/rails b/engines/registry/bin/rails new file mode 100755 index 000000000..ffb9a23cd --- /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..6a7b286a2 --- /dev/null +++ b/engines/registry/config/routes.rb @@ -0,0 +1,2 @@ +Registry::Engine.routes.draw do +end diff --git a/engines/registry/lib/registry.rb b/engines/registry/lib/registry.rb new file mode 100644 index 000000000..aeb52d908 --- /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..38144b18d --- /dev/null +++ b/engines/registry/lib/registry/version.rb @@ -0,0 +1,3 @@ +module Registry + VERSION = '0.1.0' +end