-
Notifications
You must be signed in to change notification settings - Fork 45
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add registry engine Add login support: auth based on SCC credentials -> username + password
- Loading branch information
Showing
15 changed files
with
398 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
/.bundle/ | ||
/doc/ | ||
/log/*.log | ||
/pkg/ | ||
/tmp/ | ||
.byebug_history |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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). |
4 changes: 4 additions & 0 deletions
4
engines/registry/app/controllers/registry/application_controller.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
92
engines/registry/app/controllers/registry/registry_controller.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
28
engines/registry/app/models/registry/authenticated_client.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" |
Oops, something went wrong.