Merge pull request #3035 from quay/joseph.schorr/QUAY-892/multiple-auth-scope

Multiple auth scope support in V2 auth
This commit is contained in:
josephschorr 2018-04-18 20:54:36 +03:00 committed by GitHub
commit 8d5e8fc685
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 251 additions and 118 deletions

View file

@ -5,7 +5,7 @@ from flask_principal import Identity, Principal
from mock import Mock from mock import Mock
from auth import permissions from auth import permissions
from endpoints.v2.v2auth import get_tuf_root from endpoints.v2.v2auth import _get_tuf_root
from test import testconfig from test import testconfig
from util.security.registry_jwt import QUAY_TUF_ROOT, SIGNER_TUF_ROOT, DISABLED_TUF_ROOT from util.security.registry_jwt import QUAY_TUF_ROOT, SIGNER_TUF_ROOT, DISABLED_TUF_ROOT
@ -52,7 +52,7 @@ def test_get_tuf_root(identity, expected):
app, principal = app_with_principal() app, principal = app_with_principal()
with app.test_request_context('/'): with app.test_request_context('/'):
principal.set_identity(identity) principal.set_identity(identity)
actual = get_tuf_root(Mock(), "namespace", "repo") actual = _get_tuf_root(Mock(), "namespace", "repo")
assert actual == expected, "should be %s, but was %s" % (expected, actual) assert actual == expected, "should be %s, but was %s" % (expected, actual)
@ -64,5 +64,5 @@ def test_trust_disabled(trust_enabled,tuf_root):
app, principal = app_with_principal() app, principal = app_with_principal()
with app.test_request_context('/'): with app.test_request_context('/'):
principal.set_identity(read_identity("namespace", "repo")) principal.set_identity(read_identity("namespace", "repo"))
actual = get_tuf_root(Mock(trust_enabled=trust_enabled), "namespace", "repo") actual = _get_tuf_root(Mock(trust_enabled=trust_enabled), "namespace", "repo")
assert actual == tuf_root, "should be %s, but was %s" % (tuf_root, actual) assert actual == tuf_root, "should be %s, but was %s" % (tuf_root, actual)

View file

@ -0,0 +1,105 @@
import base64
from flask import url_for
from app import instance_keys, app as original_app
from endpoints.test.shared import conduct_call
from util.security.registry_jwt import decode_bearer_token, CLAIM_TUF_ROOTS
from test.fixtures import *
@pytest.mark.parametrize('scope, username, password, expected_code, expected_scopes', [
# Invalid repository.
('repository:devtable/simple/foo/bar/baz:pull', 'devtable', 'password', 400, []),
# Invalid scopes.
('some_invalid_scope', 'devtable', 'password', 400, []),
# Invalid credentials.
('repository:devtable/simple:pull', 'devtable', 'invalid', 401, []),
# Valid credentials.
('repository:devtable/simple:pull', 'devtable', 'password', 200,
['devtable/simple:pull']),
('repository:devtable/simple:push', 'devtable', 'password', 200,
['devtable/simple:push']),
('repository:devtable/simple:pull,push', 'devtable', 'password', 200,
['devtable/simple:push,pull']),
('repository:devtable/simple:pull,push,*', 'devtable', 'password', 200,
['devtable/simple:push,pull,*']),
('repository:buynlarge/orgrepo:pull,push,*', 'devtable', 'password', 200,
['buynlarge/orgrepo:push,pull,*']),
('', 'devtable', 'password', 200, []),
# No credentials, non-public repo.
('repository:devtable/simple:pull', None, None, 200, ['devtable/simple:']),
# No credentials, public repo.
('repository:public/publicrepo:pull', None, None, 200, ['public/publicrepo:pull']),
# Reader only.
('repository:buynlarge/orgrepo:pull,push,*', 'reader', 'password', 200,
['buynlarge/orgrepo:pull']),
# Unknown repository.
('repository:devtable/unknownrepo:pull,push', 'devtable', 'password', 200,
['devtable/unknownrepo:push,pull']),
# Unknown repository in another namespace.
('repository:somenamespace/unknownrepo:pull,push', 'devtable', 'password', 200,
['somenamespace/unknownrepo:']),
# Multiple scopes.
(['repository:devtable/simple:pull,push', 'repository:devtable/complex:pull'],
'devtable', 'password', 200,
['devtable/simple:push,pull', 'devtable/complex:pull']),
# Multiple scopes with restricted behavior.
(['repository:devtable/simple:pull,push', 'repository:public/publicrepo:pull,push'],
'devtable', 'password', 200,
['devtable/simple:push,pull', 'public/publicrepo:pull']),
(['repository:devtable/simple:pull,push,*', 'repository:public/publicrepo:pull,push,*'],
'devtable', 'password', 200,
['devtable/simple:push,pull,*', 'public/publicrepo:pull']),
])
def test_generate_registry_jwt(scope, username, password, expected_code, expected_scopes,
app, client):
params = {
'service': original_app.config['SERVER_HOSTNAME'],
'scope': scope,
}
headers = {}
if username and password:
headers['Authorization'] = 'Basic %s' % (base64.b64encode('%s:%s' % (username, password)))
resp = conduct_call(client, 'v2.generate_registry_jwt', url_for, 'GET', params, {}, expected_code,
headers=headers)
if expected_code != 200:
return
token = resp.json['token']
decoded = decode_bearer_token(token, instance_keys, original_app.config)
assert decoded['iss'] == 'quay'
assert decoded['aud'] == original_app.config['SERVER_HOSTNAME']
assert decoded['sub'] == username if username else '(anonymous)'
expected_access = []
for scope in expected_scopes:
name, actions_str = scope.split(':')
actions = actions_str.split(',') if actions_str else []
expected_access.append({
'type': 'repository',
'name': name,
'actions': actions,
})
assert decoded['access'] == expected_access
assert len(decoded['context'][CLAIM_TUF_ROOTS]) == len(expected_scopes)

View file

@ -1,6 +1,7 @@
import logging import logging
import re import re
from collections import namedtuple
from cachetools import lru_cache from cachetools import lru_cache
from flask import request, jsonify from flask import request, jsonify
@ -24,13 +25,8 @@ logger = logging.getLogger(__name__)
TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour
SCOPE_REGEX_TEMPLATE = r'^repository:((?:{}\/)?((?:[\.a-zA-Z0-9_\-]+\/)*[\.a-zA-Z0-9_\-]+)):((?:push|pull|\*)(?:,(?:push|pull|\*))*)$' SCOPE_REGEX_TEMPLATE = r'^repository:((?:{}\/)?((?:[\.a-zA-Z0-9_\-]+\/)*[\.a-zA-Z0-9_\-]+)):((?:push|pull|\*)(?:,(?:push|pull|\*))*)$'
scopeResult = namedtuple('scopeResult', ['actions', 'namespace', 'repository', 'registry_and_repo',
@lru_cache(maxsize=1) 'tuf_root'])
def get_scope_regex():
hostname = re.escape(app.config['SERVER_HOSTNAME'])
scope_regex_string = SCOPE_REGEX_TEMPLATE.format(hostname)
return re.compile(scope_regex_string)
@v2_bp.route('/auth') @v2_bp.route('/auth')
@process_basic_auth @process_basic_auth
@ -44,12 +40,13 @@ def generate_registry_jwt(auth_result):
audience_param = request.args.get('service') audience_param = request.args.get('service')
logger.debug('Request audience: %s', audience_param) logger.debug('Request audience: %s', audience_param)
scope_param = request.args.get('scope') or '' scope_params = request.args.getlist('scope') or []
logger.debug('Scope request: %s', scope_param) logger.debug('Scope request: %s', scope_params)
auth_header = request.headers.get('authorization', '') auth_header = request.headers.get('authorization', '')
auth_credentials_sent = bool(auth_header) auth_credentials_sent = bool(auth_header)
# Load the auth context and verify thatg we've directly received credentials.
has_valid_auth_context = False has_valid_auth_context = False
if get_authenticated_context(): if get_authenticated_context():
has_valid_auth_context = not get_authenticated_context().is_anonymous has_valid_auth_context = not get_authenticated_context().is_anonymous
@ -58,117 +55,45 @@ def generate_registry_jwt(auth_result):
# The auth credentials sent for the user are invalid. # The auth credentials sent for the user are invalid.
raise InvalidLogin(auth_result.error_message) raise InvalidLogin(auth_result.error_message)
if not has_valid_auth_context and len(scope_params) == 0:
# In this case, we are doing an auth flow, and it's not an anonymous pull.
logger.debug('No user and no token sent for empty scope list')
raise Unauthorized()
# Build the access list for the authenticated context.
access = [] access = []
scope_results = []
for scope_param in scope_params:
scope_result = _authorize_or_downscope_request(scope_param, has_valid_auth_context)
if scope_result is None:
continue
scope_results.append(scope_result)
access.append({
'type': 'repository',
'name': scope_result.registry_and_repo,
'actions': scope_result.actions,
})
# Issue user events.
user_event_data = { user_event_data = {
'action': 'login', 'action': 'login',
} }
tuf_root = DISABLED_TUF_ROOT
if len(scope_param) > 0: # Set the user event data for when authed.
match = get_scope_regex().match(scope_param) if len(scope_results) > 0:
if match is None: if 'push' in scope_results[0].actions:
logger.debug('Match: %s', match)
logger.debug('len: %s', len(scope_param))
logger.warning('Unable to decode repository and actions: %s', scope_param)
raise InvalidRequest('Unable to decode repository and actions: %s' % scope_param)
logger.debug('Match: %s', match.groups())
registry_and_repo = match.group(1)
namespace_and_repo = match.group(2)
actions = match.group(3).split(',')
lib_namespace = app.config['LIBRARY_NAMESPACE']
namespace, reponame = parse_namespace_repository(namespace_and_repo, lib_namespace)
# Ensure that we are never creating an invalid repository.
if not REPOSITORY_NAME_REGEX.match(reponame):
logger.debug('Found invalid repository name in auth flow: %s', reponame)
if len(namespace_and_repo.split('/')) > 1:
msg = 'Nested repositories are not supported. Found: %s' % namespace_and_repo
raise NameInvalid(message=msg)
raise NameInvalid(message='Invalid repository name: %s' % namespace_and_repo)
final_actions = []
repo = model.get_repository(namespace, reponame)
repo_is_public = repo is not None and repo.is_public
invalid_repo_message = ''
if repo is not None and repo.kind != 'image':
invalid_repo_message = ((
'This repository is for managing %s resources ' + 'and not container images.') % repo.kind)
if 'push' in actions:
# Check if there is a valid user or token, as otherwise the repository cannot be
# accessed.
if has_valid_auth_context:
# Lookup the repository. If it exists, make sure the entity has modify
# permission. Otherwise, make sure the entity has create permission.
if repo:
if ModifyRepositoryPermission(namespace, reponame).can():
if repo.kind != 'image':
raise Unsupported(message=invalid_repo_message)
final_actions.append('push')
else:
logger.debug('No permission to modify repository %s/%s', namespace, reponame)
else:
user = get_authenticated_user()
if CreateRepositoryPermission(namespace).can() and user is not None:
logger.debug('Creating repository: %s/%s', namespace, reponame)
model.create_repository(namespace, reponame, user)
final_actions.append('push')
else:
logger.debug('No permission to create repository %s/%s', namespace, reponame)
if 'pull' in actions:
# Grant pull if the user can read the repo or it is public.
if ReadRepositoryPermission(namespace, reponame).can() or repo_is_public:
if repo is not None and repo.kind != 'image':
raise Unsupported(message=invalid_repo_message)
final_actions.append('pull')
else:
logger.debug('No permission to pull repository %s/%s', namespace, reponame)
if '*' in actions:
# Grant * user is admin
if AdministerRepositoryPermission(namespace, reponame).can():
if repo is not None and repo.kind != 'image':
raise Unsupported(message=invalid_repo_message)
final_actions.append('*')
else:
logger.debug("No permission to administer repository %s/%s", namespace, reponame)
# Add the access for the JWT.
access.append({
'type': 'repository',
'name': registry_and_repo,
'actions': final_actions,
})
# Set the user event data for the auth.
if 'push' in final_actions:
user_action = 'push_start' user_action = 'push_start'
elif 'pull' in final_actions: elif 'pull' in scope_results[0].actions:
user_action = 'pull_start' user_action = 'pull_start'
else: else:
user_action = 'login' user_action = 'login'
user_event_data = { user_event_data = {
'action': user_action, 'action': user_action,
'repository': reponame, 'namespace': scope_results[0].namespace,
'namespace': namespace, 'repository': scope_results[0].repository,
} }
tuf_root = get_tuf_root(repo, namespace, reponame)
elif not has_valid_auth_context:
# In this case, we are doing an auth flow, and it's not an anonymous pull
logger.debug('No user and no token sent for empty scope list')
raise Unauthorized()
# Send the user event. # Send the user event.
if get_authenticated_user() is not None: if get_authenticated_user() is not None:
@ -176,13 +101,22 @@ def generate_registry_jwt(auth_result):
event.publish_event_data('docker-cli', user_event_data) event.publish_event_data('docker-cli', user_event_data)
# Build the signed JWT. # Build the signed JWT.
context, subject = build_context_and_subject(get_authenticated_context(), tuf_root=tuf_root) tuf_roots = {'%s/%s' % (scope_result.namespace, scope_result.repository): scope_result.tuf_root
for scope_result in scope_results}
context, subject = build_context_and_subject(get_authenticated_context(), tuf_roots=tuf_roots)
token = generate_bearer_token(audience_param, subject, context, access, token = generate_bearer_token(audience_param, subject, context, access,
TOKEN_VALIDITY_LIFETIME_S, instance_keys) TOKEN_VALIDITY_LIFETIME_S, instance_keys)
return jsonify({'token': token}) return jsonify({'token': token})
def get_tuf_root(repo, namespace, reponame): @lru_cache(maxsize=1)
def _get_scope_regex():
hostname = re.escape(app.config['SERVER_HOSTNAME'])
scope_regex_string = SCOPE_REGEX_TEMPLATE.format(hostname)
return re.compile(scope_regex_string)
def _get_tuf_root(repo, namespace, reponame):
if not features.SIGNING or repo is None or not repo.trust_enabled: if not features.SIGNING or repo is None or not repo.trust_enabled:
return DISABLED_TUF_ROOT return DISABLED_TUF_ROOT
@ -190,3 +124,94 @@ def get_tuf_root(repo, namespace, reponame):
if ModifyRepositoryPermission(namespace, reponame).can(): if ModifyRepositoryPermission(namespace, reponame).can():
return SIGNER_TUF_ROOT return SIGNER_TUF_ROOT
return QUAY_TUF_ROOT return QUAY_TUF_ROOT
def _authorize_or_downscope_request(scope_param, has_valid_auth_context):
if len(scope_param) == 0:
if not has_valid_auth_context:
# In this case, we are doing an auth flow, and it's not an anonymous pull.
logger.debug('No user and no token sent for empty scope list')
raise Unauthorized()
return None
match = _get_scope_regex().match(scope_param)
if match is None:
logger.debug('Match: %s', match)
logger.debug('len: %s', len(scope_param))
logger.warning('Unable to decode repository and actions: %s', scope_param)
raise InvalidRequest('Unable to decode repository and actions: %s' % scope_param)
logger.debug('Match: %s', match.groups())
registry_and_repo = match.group(1)
namespace_and_repo = match.group(2)
actions = match.group(3).split(',')
lib_namespace = app.config['LIBRARY_NAMESPACE']
namespace, reponame = parse_namespace_repository(namespace_and_repo, lib_namespace)
# Ensure that we are never creating an invalid repository.
if not REPOSITORY_NAME_REGEX.match(reponame):
logger.debug('Found invalid repository name in auth flow: %s', reponame)
if len(namespace_and_repo.split('/')) > 1:
msg = 'Nested repositories are not supported. Found: %s' % namespace_and_repo
raise NameInvalid(message=msg)
raise NameInvalid(message='Invalid repository name: %s' % namespace_and_repo)
final_actions = []
repo = model.get_repository(namespace, reponame)
repo_is_public = repo is not None and repo.is_public
invalid_repo_message = ''
if repo is not None and repo.kind != 'image':
invalid_repo_message = ((
'This repository is for managing %s resources ' + 'and not container images.') % repo.kind)
if 'push' in actions:
# Check if there is a valid user or token, as otherwise the repository cannot be
# accessed.
if has_valid_auth_context:
# Lookup the repository. If it exists, make sure the entity has modify
# permission. Otherwise, make sure the entity has create permission.
if repo:
if ModifyRepositoryPermission(namespace, reponame).can():
if repo.kind != 'image':
raise Unsupported(message=invalid_repo_message)
final_actions.append('push')
else:
logger.debug('No permission to modify repository %s/%s', namespace, reponame)
else:
user = get_authenticated_user()
if CreateRepositoryPermission(namespace).can() and user is not None:
logger.debug('Creating repository: %s/%s', namespace, reponame)
model.create_repository(namespace, reponame, user)
final_actions.append('push')
else:
logger.debug('No permission to create repository %s/%s', namespace, reponame)
if 'pull' in actions:
# Grant pull if the user can read the repo or it is public.
if ReadRepositoryPermission(namespace, reponame).can() or repo_is_public:
if repo is not None and repo.kind != 'image':
raise Unsupported(message=invalid_repo_message)
final_actions.append('pull')
else:
logger.debug('No permission to pull repository %s/%s', namespace, reponame)
if '*' in actions:
# Grant * user is admin
if AdministerRepositoryPermission(namespace, reponame).can():
if repo is not None and repo.kind != 'image':
raise Unsupported(message=invalid_repo_message)
final_actions.append('*')
else:
logger.debug("No permission to administer repository %s/%s", namespace, reponame)
return scopeResult(actions=final_actions, namespace=namespace, repository=reponame,
registry_and_repo=registry_and_repo,
tuf_root=_get_tuf_root(repo, namespace, reponame))

View file

@ -8,6 +8,7 @@ logger = logging.getLogger(__name__)
ANONYMOUS_SUB = '(anonymous)' ANONYMOUS_SUB = '(anonymous)'
ALGORITHM = 'RS256' ALGORITHM = 'RS256'
CLAIM_TUF_ROOTS = 'com.apostille.roots'
CLAIM_TUF_ROOT = 'com.apostille.root' CLAIM_TUF_ROOT = 'com.apostille.root'
QUAY_TUF_ROOT = 'quay' QUAY_TUF_ROOT = 'quay'
SIGNER_TUF_ROOT = 'signer' SIGNER_TUF_ROOT = 'signer'
@ -106,18 +107,20 @@ def _generate_jwt_object(audience, subject, context, access, lifetime_s, issuer,
return jwt.encode(token_data, private_key, ALGORITHM, headers=token_headers) return jwt.encode(token_data, private_key, ALGORITHM, headers=token_headers)
def build_context_and_subject(auth_context=None, tuf_root=None): def build_context_and_subject(auth_context=None, tuf_roots=None):
""" Builds the custom context field for the JWT signed token and returns it, """ Builds the custom context field for the JWT signed token and returns it,
along with the subject for the JWT signed token. """ along with the subject for the JWT signed token. """
# Serialize to a dictionary. # Serialize to a dictionary.
context = auth_context.to_signed_dict() if auth_context else {} context = auth_context.to_signed_dict() if auth_context else {}
# Default to quay root if not explicitly granted permission to see signer root # TODO: remove once Apostille has been upgraded to not use the single root.
if not tuf_root: single_root = (tuf_roots.values()[0]
tuf_root = QUAY_TUF_ROOT if tuf_roots is not None and len(tuf_roots) == 1
else DISABLED_TUF_ROOT)
context.update({ context.update({
CLAIM_TUF_ROOT: tuf_root CLAIM_TUF_ROOTS: tuf_roots,
CLAIM_TUF_ROOT: single_root,
}) })
if not auth_context or auth_context.is_anonymous: if not auth_context or auth_context.is_anonymous:

View file

@ -242,7 +242,7 @@ class ImplementedTUFMetadataAPI(TUFMetadataAPIInterface):
'name': gun, 'name': gun,
'actions': actions, 'actions': actions,
}] }]
context, subject = build_context_and_subject(auth_context=None, tuf_root=SIGNER_TUF_ROOT) context, subject = build_context_and_subject(auth_context=None, tuf_roots={gun: SIGNER_TUF_ROOT})
token = generate_bearer_token(self._config["SERVER_HOSTNAME"], subject, context, access, token = generate_bearer_token(self._config["SERVER_HOSTNAME"], subject, context, access,
TOKEN_VALIDITY_LIFETIME_S, self._instance_keys) TOKEN_VALIDITY_LIFETIME_S, self._instance_keys)
return {'Authorization': 'Bearer %s' % token} return {'Authorization': 'Bearer %s' % token}