Skip to content

Commit

Permalink
Merge branch 'master' into get_702_working_after_debian_refactor
Browse files Browse the repository at this point in the history
  • Loading branch information
Adnilson authored Apr 17, 2024
2 parents 062d8a1 + cd4559e commit 71e0615
Show file tree
Hide file tree
Showing 30 changed files with 1,032 additions and 1 deletion.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,4 @@ gem 'terminal-table', '~> 3.0'

# needed by rmt-server-pubcloud
gem 'jwt', '~> 2.1'
gem 'base32'
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions ci/rmt-configure
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions config/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
3 changes: 3 additions & 0 deletions engines/registry/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.bundle/
log/*.log
pkg/
3 changes: 3 additions & 0 deletions engines/registry/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module Registry
class ApplicationController < ActionController::Base
end
end
103 changes: 103 additions & 0 deletions engines/registry/app/controllers/registry/registry_controller.rb
Original file line number Diff line number Diff line change
@@ -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
107 changes: 107 additions & 0 deletions engines/registry/app/models/access_scope.rb
Original file line number Diff line number Diff line change
@@ -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:
# `<type>[(<class>)]:<namespace>/<image>:<actions>`
# `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 || '<anonymous>'}': #{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(/(?<!\*)\*(?!\*)/, '[^/]*').gsub('**', '.*')
repos.each do |repo|
next if filtered_repos.include? repo

filtered_repos << repo unless (repo =~ /^#{pattern}$/).nil?
end
end
filtered_repos
end
end
46 changes: 46 additions & 0 deletions engines/registry/app/models/access_token.rb
Original file line number Diff line number Diff line change
@@ -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 AccessToken
def initialize(account, service, granted_scopes)
@account = account
@service = service # "SUSE Linux OCI 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'] = '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
Loading

0 comments on commit 71e0615

Please sign in to comment.