diff --git a/endpoints/api/signing.py b/endpoints/api/signing.py index 34096187b..454482850 100644 --- a/endpoints/api/signing.py +++ b/endpoints/api/signing.py @@ -11,17 +11,6 @@ from endpoints.api import (require_repo_read, path_param, logger = logging.getLogger(__name__) -def _default_signed_tags_for_repository(namespace, repository): - """ Fetches the tags in the targets/releases delegation, which is the one the docker client will trust. """ - tag_data, _ = tuf_metadata_api.get_default_tags(namespace, repository) - if not tag_data: - return {'tags': None} - - return { - 'tags': tag_data.keys() - } - - @show_if(features.SIGNING) @resource('/v1/repository//signatures') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @@ -32,6 +21,10 @@ class RepositorySignatures(RepositoryParamResource): @nickname('getRepoSignatures') @disallow_for_app_repositories def get(self, namespace, repository): - """ Fetches the list of signed tags for the repository""" - return _default_signed_tags_for_repository(namespace, repository) + """ Fetches the list of signed tags for the repository""" + tag_data, expiration = tuf_metadata_api.get_default_tags_with_expiration(namespace, repository) + return { + 'tags': tag_data, + 'expiration': expiration + } diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index c9b72c87c..425ad1682 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -1,4 +1,5 @@ import pytest +from flask_principal import AnonymousIdentity from endpoints.api import api from endpoints.api.team import OrganizationTeamSyncing @@ -10,6 +11,7 @@ from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_f TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'} BUILD_PARAMS = {'build_uuid': 'test-1234'} +REPO_PARAMS = {'repository': 'devtable/someapp'} @pytest.mark.parametrize('resource,method,params,body,identity,expected', [ (OrganizationTeamSyncing, 'POST', TEAM_PARAMS, {}, None, 403), @@ -37,10 +39,9 @@ BUILD_PARAMS = {'build_uuid': 'test-1234'} (SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'reader', 403), (SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'devtable', 404), - (RepositorySignatures, 'GET', 401, None, None), - (RepositorySignatures, 'GET', 403, 'freshuser', None), - (RepositorySignatures, 'GET', 403, 'reader', None), - (RepositorySignatures, 'GET', 404, 'devtable', None), + (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'freshuser', 403), + (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'reader', 403), + (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'devtable', 200), ]) def test_api_security(resource, method, params, body, identity, expected, client): with client_with_identity(identity, client) as cl: diff --git a/endpoints/api/test/test_signing.py b/endpoints/api/test/test_signing.py index 5d7744be5..93ac94be6 100644 --- a/endpoints/api/test/test_signing.py +++ b/endpoints/api/test/test_signing.py @@ -30,14 +30,14 @@ def tags_equal(expected, actual): return expected == actual @pytest.mark.parametrize('targets,expected', [ - (VALID_TARGETS, {'tags':['latest', 'test_tag']}), - ({'bad': 'tags'}, ({'tags': ['bad']})), - ({}, ({'tags': None})), - (None, ({'tags': None})), # API returns None on exceptions + (VALID_TARGETS, {'tags': VALID_TARGETS, 'expiration': 'expires'}), + ({'bad': 'tags'}, {'tags': {'bad': 'tags'}, 'expiration': 'expires'}), + ({}, {'tags': {}, 'expiration': 'expires'}), + (None, {'tags': None, 'expiration': 'expires'}), # API returns None on exceptions ]) def test_get_signatures(targets, expected, client): with patch('endpoints.api.signing.tuf_metadata_api') as mock_tuf: - mock_tuf.get_default_tags.return_value = (targets, False) + mock_tuf.get_default_tags_with_expiration.return_value = (targets, 'expires') with client_with_identity('devtable', client) as cl: params = {'repository': 'devtable/repo'} assert tags_equal(expected, conduct_api_call(cl, RepositorySignatures, 'GET', params, None, 200).json) diff --git a/util/tufmetadata/api.py b/util/tufmetadata/api.py index f30a262cd..2ece0c307 100644 --- a/util/tufmetadata/api.py +++ b/util/tufmetadata/api.py @@ -36,40 +36,43 @@ class TUFMetadataAPI(object): self._config = config self._client = client or config['HTTPCLIENT'] - def get_default_tags(self, namespace, repository): - """ Gets the tag -> sha mappings in the 'targets/releases' delegation + def get_default_tags_with_expiration(self, namespace, repository, targets_file=None): + """ Gets the tag -> sha mappings in the 'targets/releases' delegation + Returns tags, their hashes, and their """ + if not targets_file: + targets_file = 'targets/releases.json' gun = "%s/%s" % (namespace, repository) + try: - response = self._get(gun, "targets/releases.json") - json_response = response.json() - targets = self._parse_targets(json_response) + response = self._get(gun, targets_file) + signed = self._parse_signed(response.json()) + targets = signed.get('targets') + expiration = signed.get('expires') except requests.exceptions.Timeout: logger.exception('Timeout when trying to get metadata for %s', gun) - return None, True + return None, None except requests.exceptions.ConnectionError: logger.exception('Connection error when trying to get metadata for %s', gun) - return None, True + return None, None except (requests.exceptions.RequestException, ValueError): logger.exception('Failed to get metadata for %s', gun) - return None, False + return None, None except Non200ResponseException as ex: - return None, ex.response.status_code != 404 and ex.response.status_code != 400 + logger.exception('Failed request for %s: %s', gun, str(ex)) + return None, None except InvalidMetadataException as ex: logger.exception('Failed to parse targets from metadata', str(ex)) - return None, False + return None, None - return targets, False + return targets, expiration - def _parse_targets(self, json_response): + def _parse_signed(self, json_response): """ Attempts to parse the targets from a metadata response """ signed = json_response.get('signed') if not signed: raise InvalidMetadataException("Could not find `signed` in metadata: %s" % json_response) - targets = signed.get('targets') - if not targets: - raise InvalidMetadataException("Could not find `targets` in metadata: %s" % json_response) - return targets + return signed def _auth_header(self, gun): """ Generate a registry auth token for apostille""" diff --git a/util/tufmetadata/test/test_tufmetadata.py b/util/tufmetadata/test/test_tufmetadata.py index 98f36ccb5..f46097768 100644 --- a/util/tufmetadata/test/test_tufmetadata.py +++ b/util/tufmetadata/test/test_tufmetadata.py @@ -35,8 +35,8 @@ valid_response = { @pytest.mark.parametrize('response_code,response_body,expected', [ - (200, valid_response, valid_response['signed']['targets']), - (200, {'garbage': 'data'}, None) + (200, valid_response, (valid_response['signed']['targets'], '2020-03-30T18:55:26.594764859-04:00')), + (200, {'garbage': 'data'}, (None, None)) ]) def test_get_metadata(response_code, response_body, expected): app = Flask(__name__) @@ -46,8 +46,8 @@ def test_get_metadata(response_code, response_body, expected): request.json.return_value = response_body client.request.return_value = request tuf_api = api.TUFMetadataAPI(app, app.config, client=client) - tags, _ = tuf_api.get_default_tags('quay', 'quay') - assert tags == expected + response = tuf_api.get_default_tags_with_expiration('quay', 'quay') + assert response == expected @pytest.mark.parametrize('connection_error,response_code,exception', [ (True, 200, requests.exceptions.Timeout), @@ -67,5 +67,6 @@ def test_get_metadata_exception(connection_error, response_code, exception): client = mock.Mock(request=request) client.request.side_effect = exception tuf_api = api.TUFMetadataAPI(app, app.config, client=client) - tags, _ = tuf_api.get_default_tags('quay', 'quay') - assert tags == None \ No newline at end of file + tags, expiration = tuf_api.get_default_tags_with_expiration('quay', 'quay') + assert tags == None + assert expiration == None \ No newline at end of file