Skip to content

Commit

Permalink
Add registry engine
Browse files Browse the repository at this point in the history
Add registry engine
Add login support:
  auth based on SCC credentials -> username + password
  • Loading branch information
jesusbv committed Feb 19, 2024
1 parent 024478b commit 373c9ec
Show file tree
Hide file tree
Showing 15 changed files with 398 additions and 0 deletions.
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 6 additions & 0 deletions engines/registry/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/.bundle/
/doc/
/log/*.log
/pkg/
/tmp/
.byebug_history
28 changes: 28 additions & 0 deletions engines/registry/README.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module Registry
class ApplicationController < ActionController::API
end
end
92 changes: 92 additions & 0 deletions engines/registry/app/controllers/registry/registry_controller.rb
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions engines/registry/app/models/registry/access_scope.rb
Original file line number Diff line number Diff line change
@@ -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:
# `<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

# 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
46 changes: 46 additions & 0 deletions engines/registry/app/models/registry/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 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
5 changes: 5 additions & 0 deletions engines/registry/app/models/registry/application_record.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module Registry
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
end
28 changes: 28 additions & 0 deletions engines/registry/app/models/registry/authenticated_client.rb
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions engines/registry/app/models/registry/catalog_client.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions engines/registry/bin/rails
Original file line number Diff line number Diff line change
@@ -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"
Loading

0 comments on commit 373c9ec

Please sign in to comment.