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,14 +55,87 @@ 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 'push' in scope_results[0].actions:
user_action = 'push_start'
elif 'pull' in scope_results[0].actions:
user_action = 'pull_start'
else:
user_action = 'login'
user_event_data = {
'action': user_action,
'namespace': scope_results[0].namespace,
'repository': scope_results[0].repository,
}
# Send the user event.
if get_authenticated_user() is not None:
event = userevents.get_event(get_authenticated_user().username)
event.publish_event_data('docker-cli', user_event_data)
# Build the signed JWT.
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_VALIDITY_LIFETIME_S, instance_keys)
return jsonify({'token': token})
@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:
return DISABLED_TUF_ROOT
# Users with write access to a repo will see signer-rooted TUF metadata
if ModifyRepositoryPermission(namespace, reponame).can():
return SIGNER_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: if match is None:
logger.debug('Match: %s', match) logger.debug('Match: %s', match)
logger.debug('len: %s', len(scope_param)) logger.debug('len: %s', len(scope_param))
@ -93,7 +163,6 @@ def generate_registry_jwt(auth_result):
final_actions = [] final_actions = []
repo = model.get_repository(namespace, reponame) repo = model.get_repository(namespace, reponame)
repo_is_public = repo is not None and repo.is_public repo_is_public = repo is not None and repo.is_public
invalid_repo_message = '' invalid_repo_message = ''
if repo is not None and repo.kind != 'image': if repo is not None and repo.kind != 'image':
@ -143,50 +212,6 @@ def generate_registry_jwt(auth_result):
else: else:
logger.debug("No permission to administer repository %s/%s", namespace, reponame) logger.debug("No permission to administer repository %s/%s", namespace, reponame)
# Add the access for the JWT. return scopeResult(actions=final_actions, namespace=namespace, repository=reponame,
access.append({ registry_and_repo=registry_and_repo,
'type': 'repository', tuf_root=_get_tuf_root(repo, namespace, reponame))
'name': registry_and_repo,
'actions': final_actions,
})
# Set the user event data for the auth.
if 'push' in final_actions:
user_action = 'push_start'
elif 'pull' in final_actions:
user_action = 'pull_start'
else:
user_action = 'login'
user_event_data = {
'action': user_action,
'repository': reponame,
'namespace': namespace,
}
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.
if get_authenticated_user() is not None:
event = userevents.get_event(get_authenticated_user().username)
event.publish_event_data('docker-cli', user_event_data)
# Build the signed JWT.
context, subject = build_context_and_subject(get_authenticated_context(), tuf_root=tuf_root)
token = generate_bearer_token(audience_param, subject, context, access,
TOKEN_VALIDITY_LIFETIME_S, instance_keys)
return jsonify({'token': token})
def get_tuf_root(repo, namespace, reponame):
if not features.SIGNING or repo is None or not repo.trust_enabled:
return DISABLED_TUF_ROOT
# Users with write access to a repo will see signer-rooted TUF metadata
if ModifyRepositoryPermission(namespace, reponame).can():
return SIGNER_TUF_ROOT
return QUAY_TUF_ROOT

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}