diff --git a/data/database.py b/data/database.py index caf4f560b..457b9f77a 100644 --- a/data/database.py +++ b/data/database.py @@ -568,6 +568,7 @@ class Repository(BaseModel): description = FullIndexedTextField(match_function=db_match_func, null=True) badge_token = CharField(default=uuid_generator) kind = EnumField(RepositoryKind) + trust_enabled = BooleanField(default=False) class Meta: database = db diff --git a/data/interfaces/v2.py b/data/interfaces/v2.py index a119d9b93..f949ddc4e 100644 --- a/data/interfaces/v2.py +++ b/data/interfaces/v2.py @@ -13,7 +13,7 @@ _MEDIA_TYPE = "application/vnd.docker.distribution.manifest.v1+prettyjws" class Repository(namedtuple('Repository', ['id', 'name', 'namespace_name', 'description', - 'is_public', 'kind'])): + 'is_public', 'kind', 'trust_enabled'])): """ Repository represents a namespaced collection of tags. :type id: int @@ -22,6 +22,7 @@ class Repository(namedtuple('Repository', ['id', 'name', 'namespace_name', 'desc :type description: string :type is_public: bool :type kind: string + :type trust_enabled: bool """ class ManifestJSON(namedtuple('ManifestJSON', ['digest', 'json', 'media_type'])): @@ -536,6 +537,7 @@ def _repository_for_repo(repo): description=repo.description, is_public=model.repository.is_repository_public(repo), kind=model.repository.get_repo_kind_name(repo), + trust_enabled=repo.trust_enabled, ) diff --git a/data/migrations/versions/ed01e313d3cb_add_trust_enabled_to_repository.py b/data/migrations/versions/ed01e313d3cb_add_trust_enabled_to_repository.py new file mode 100644 index 000000000..323f337c8 --- /dev/null +++ b/data/migrations/versions/ed01e313d3cb_add_trust_enabled_to_repository.py @@ -0,0 +1,34 @@ +"""Add trust_enabled to repository + +Revision ID: ed01e313d3cb +Revises: c3d4b7ebcdf7 +Create Date: 2017-04-14 17:38:03.319695 + +""" + +# revision identifiers, used by Alembic. +revision = 'ed01e313d3cb' +down_revision = 'c3d4b7ebcdf7' + +from alembic import op +import sqlalchemy as sa + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('repository', sa.Column('trust_enabled', sa.Boolean(), nullable=False)) + ### end Alembic commands ### + op.bulk_insert(tables.logentrykind, [ + {'name': 'change_repo_trust'}, + ]) + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('repository', 'trust_enabled') + ### end Alembic commands ### + + op.execute(tables + .logentrykind + .delete() + .where(tables. + logentrykind.name == op.inline_literal('change_repo_trust'))) diff --git a/data/model/repository.py b/data/model/repository.py index 7af1c1c48..bf14dc97e 100644 --- a/data/model/repository.py +++ b/data/model/repository.py @@ -279,6 +279,11 @@ def unstar_repository(user, repository): .execute()) except Star.DoesNotExist: raise DataModelException('Star not found.') + + +def set_trust(repo, trust_enabled): + repo.trust_enabled = trust_enabled + repo.save() def get_user_starred_repositories(user, kind_filter='image'): diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 86f8fe4d4..fa890708e 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -15,8 +15,8 @@ from endpoints.api import (truthy_bool, format_date, nickname, log_action, valid require_repo_read, require_repo_write, require_repo_admin, RepositoryParamResource, resource, query_param, parse_args, ApiResource, request_error, require_scope, path_param, page_support, parse_args, - query_param, truthy_bool, disallow_for_app_repositories) -from endpoints.exception import Unauthorized, NotFound, InvalidRequest, ExceedsLicenseException + query_param, truthy_bool, disallow_for_app_repositories, show_if) +from endpoints.exception import Unauthorized, NotFound, InvalidRequest, ExceedsLicenseException, DownstreamIssue from endpoints.api.billing import lookup_allowed_private_repos, get_namespace_plan from endpoints.api.subscribe import check_repository_usage @@ -377,6 +377,7 @@ class Repository(RepositoryParamResource): 'is_organization': repo.namespace_user.organization, 'is_starred': is_starred, 'status_token': repo.badge_token if not is_public else '', + 'trust_enabled': repo.trust_enabled, } if stats is not None: @@ -464,3 +465,46 @@ class RepositoryVisibility(RepositoryParamResource): {'repo': repository, 'namespace': namespace, 'visibility': values['visibility']}, repo=repo) return {'success': True} + + +@resource('/v1/repository//changetrust') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +class RepositoryTrust(RepositoryParamResource): + """ Custom verb for changing the trust settings of the repository. """ + schemas = { + 'ChangeRepoTrust': { + 'type': 'object', + 'description': 'Change the trust settings for the repository.', + 'required': [ + 'trust_enabled', + ], + 'properties': { + 'trust_enabled': { + 'type': 'boolean', + 'description': 'Whether or not signing is enabled for the repository.' + }, + } + } + } + + @show_if(features.SIGNING) + @require_repo_admin + @nickname('changeRepoTrust') + @validate_json_request('ChangeRepoTrust') + def post(self, namespace, repository): + """ Change the visibility of a repository. """ + repo = model.repository.get_repository(namespace, repository) + if not repo: + raise NotFound() + + if not tuf_metadata_api.delete_metadata(namespace, repository): + raise DownstreamIssue({'message': 'Unable to delete downstream trust metadata'}) + + values = request.get_json() + model.repository.set_trust(repo, values['trust_enabled']) + + log_action('change_repo_trust', namespace, + {'repo': repository, 'namespace': namespace, 'trust_enabled': values['trust_enabled']}, + repo=repo) + + return {'success': True} diff --git a/endpoints/api/test/test_repository.py b/endpoints/api/test/test_repository.py new file mode 100644 index 000000000..f686fd9e4 --- /dev/null +++ b/endpoints/api/test/test_repository.py @@ -0,0 +1,42 @@ +import pytest + +from endpoints.api.test.shared import client_with_identity, conduct_api_call +from endpoints.api.repository import RepositoryTrust +from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file +from mock import patch, ANY, MagicMock + + +INVALID_RESPONSE = { + u'detail': u"u'invalid_req' is not of type 'boolean'", + u'error_message': u"u'invalid_req' is not of type 'boolean'", + u'error_type': u'invalid_request', + u'status': 400, + u'title': u'invalid_request', + u'type': u'http://localhost/api/v1/error/invalid_request' +} + +NOT_FOUND_RESPONSE = { + u'detail': u'Not Found', + u'error_message': u'Not Found', + u'error_type': u'not_found', + u'message': u'You have requested this URI [/api/v1/repository/devtable/repo/changetrust] but did you mean /api/v1/repository//changetrust or /api/v1/repository//changevisibility or /api/v1/repository//tag//images ?', + u'status': 404, + u'title': u'not_found', + u'type': u'http://localhost/api/v1/error/not_found' +} + + +@pytest.mark.parametrize('trust_enabled,repo_found,expected_body,expected_status', [ + (True, True,{'success': True}, 200), + (False, True, {'success': True}, 200), + (False, False, NOT_FOUND_RESPONSE, 404), + ('invalid_req', False, INVALID_RESPONSE , 400), +]) +def test_post_changetrust(trust_enabled, repo_found, expected_body, expected_status, client): + with patch('endpoints.api.repository.tuf_metadata_api'): + with patch('endpoints.api.repository.model') as mock_model: + mock_model.repository.get_repository.return_value = MagicMock() if repo_found else None + with client_with_identity('devtable', client) as cl: + params = {'repository': 'devtable/repo'} + request_body = {'trust_enabled': trust_enabled} + assert expected_body == conduct_api_call(cl, RepositoryTrust, 'POST', params, request_body, expected_status).json diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index 425ad1682..bfcae8b99 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -7,6 +7,7 @@ from endpoints.api.test.shared import client_with_identity, conduct_api_call from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource from endpoints.api.superuser import SuperUserRepositoryBuildStatus from endpoints.api.signing import RepositorySignatures +from endpoints.api.repository import RepositoryTrust from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'} @@ -42,6 +43,11 @@ REPO_PARAMS = {'repository': 'devtable/someapp'} (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'freshuser', 403), (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'reader', 403), (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'devtable', 200), + + (RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, None, 403), + (RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, 'freshuser', 403), + (RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, 'reader', 403), + (RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, 'devtable', 404), ]) def test_api_security(resource, method, params, body, identity, expected, client): with client_with_identity(identity, client) as cl: diff --git a/endpoints/v2/test/test_v2auth.py b/endpoints/v2/test/test_v2auth.py index 38b4167fd..ec0502a3a 100644 --- a/endpoints/v2/test/test_v2auth.py +++ b/endpoints/v2/test/test_v2auth.py @@ -3,10 +3,14 @@ import pytest import flask from flask_principal import Identity, Principal -from endpoints.v2.v2auth import get_tuf_root +from endpoints.v2.v2auth import get_tuf_root from auth import permissions -from util.security.registry_jwt import QUAY_TUF_ROOT, SIGNER_TUF_ROOT +from util.security.registry_jwt import QUAY_TUF_ROOT, SIGNER_TUF_ROOT, DISABLED_TUF_ROOT +from test import testconfig +from mock import Mock + + def admin_identity(namespace, reponame): identity = Identity('admin') identity.provides.add(permissions._RepositoryNeed(namespace, reponame, 'admin')) @@ -27,7 +31,7 @@ def read_identity(namespace, reponame): def app_with_principal(): app = flask.Flask(__name__) - app.config.update(SECRET_KEY='secret', TESTING=True) + app.config.from_object(testconfig.TestConfig()) principal = Principal(app) return app, principal @@ -44,5 +48,17 @@ def test_get_tuf_root(identity, expected): app, principal = app_with_principal() with app.test_request_context('/'): principal.set_identity(identity) - actual = get_tuf_root("namespace", "repo") + actual = get_tuf_root(Mock(), "namespace", "repo") assert actual == expected, "should be %s, but was %s" % (expected, actual) + + +@pytest.mark.parametrize('trust_enabled,tuf_root', [ + (True, QUAY_TUF_ROOT), + (False, DISABLED_TUF_ROOT), +]) +def test_trust_disabled(trust_enabled,tuf_root): + app, principal = app_with_principal() + with app.test_request_context('/'): + principal.set_identity(read_identity("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) diff --git a/endpoints/v2/v2auth.py b/endpoints/v2/v2auth.py index 12e7863a8..0d9e8ffb0 100644 --- a/endpoints/v2/v2auth.py +++ b/endpoints/v2/v2auth.py @@ -4,6 +4,7 @@ import re from cachetools import lru_cache from flask import request, jsonify, abort +import features from app import app, userevents, instance_keys from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token from auth.decorators import process_basic_auth @@ -15,7 +16,8 @@ from endpoints.v2.errors import InvalidLogin, NameInvalid, InvalidRequest, Unsup from data.interfaces.v2 import pre_oci_model as model from util.cache import no_cache from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX -from util.security.registry_jwt import generate_bearer_token, build_context_and_subject, QUAY_TUF_ROOT, SIGNER_TUF_ROOT +from util.security.registry_jwt import (generate_bearer_token, build_context_and_subject, QUAY_TUF_ROOT, + SIGNER_TUF_ROOT, DISABLED_TUF_ROOT) logger = logging.getLogger(__name__) @@ -64,7 +66,7 @@ def generate_registry_jwt(auth_result): user_event_data = { 'action': 'login', } - tuf_root = QUAY_TUF_ROOT + tuf_root = DISABLED_TUF_ROOT if len(scope_param) > 0: match = get_scope_regex().match(scope_param) @@ -164,8 +166,7 @@ def generate_registry_jwt(auth_result): 'repository': reponame, 'namespace': namespace, } - - tuf_root = get_tuf_root(namespace, reponame) + tuf_root = get_tuf_root(repo, namespace, reponame) elif user is None and token is None: # In this case, we are doing an auth flow, and it's not an anonymous pull @@ -184,7 +185,10 @@ def generate_registry_jwt(auth_result): return jsonify({'token': token}) -def get_tuf_root(namespace, reponame): +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 diff --git a/initdb.py b/initdb.py index 855ee11b3..efcb99da8 100644 --- a/initdb.py +++ b/initdb.py @@ -296,6 +296,7 @@ def initialize_database(): LogEntryKind.create(name='change_repo_permission') LogEntryKind.create(name='delete_repo_permission') LogEntryKind.create(name='change_repo_visibility') + LogEntryKind.create(name='change_repo_trust') LogEntryKind.create(name='add_repo_accesstoken') LogEntryKind.create(name='delete_repo_accesstoken') LogEntryKind.create(name='set_repo_description') diff --git a/static/js/directives/ui/logs-view.js b/static/js/directives/ui/logs-view.js index 7bdf43b97..d1054b026 100644 --- a/static/js/directives/ui/logs-view.js +++ b/static/js/directives/ui/logs-view.js @@ -136,6 +136,13 @@ angular.module('quay').directive('logsView', function () { 'create_tag': 'Tag {tag} created in repository {namespace}/{repo} on image {image} by user {username}', 'move_tag': 'Tag {tag} moved from image {original_image} to image {image} in repository {namespace}/{repo} by user {username}', 'change_repo_visibility': 'Change visibility for repository {namespace}/{repo} to {visibility}', + 'change_repo_trust': function(metadata) { + if (metadata.trust_enabled) { + return 'Trust enabled for {namespace}/{repo}'; + } else { + return 'Trust disabled for {namespace}/{repo}'; + } + }, 'add_repo_accesstoken': 'Create access token {token} in repository {repo}', 'delete_repo_accesstoken': 'Delete access token {token} in repository {repo}', 'set_repo_description': 'Change description for repository {namespace}/{repo}', @@ -265,6 +272,7 @@ angular.module('quay').directive('logsView', function () { 'change_repo_permission': 'Change repository permission', 'delete_repo_permission': 'Remove user permission from repository', 'change_repo_visibility': 'Change repository visibility', + 'change_repo_trust': 'Change repository trust settings', 'add_repo_accesstoken': 'Create access token', 'delete_repo_accesstoken': 'Delete access token', 'set_repo_description': 'Change repository description', diff --git a/test/data/test.db b/test/data/test.db index abfe7ef07..c8f6dc912 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/util/security/registry_jwt.py b/util/security/registry_jwt.py index 373e990a7..e6d39d656 100644 --- a/util/security/registry_jwt.py +++ b/util/security/registry_jwt.py @@ -11,6 +11,7 @@ ALGORITHM = 'RS256' CLAIM_TUF_ROOT = 'com.apostille.root' QUAY_TUF_ROOT = 'quay' SIGNER_TUF_ROOT = 'signer' +DISABLED_TUF_ROOT = '$disabled' # The number of allowed seconds of clock skew for a JWT. The iat, nbf and exp are adjusted with this # count.