Add flag to enable trust per repo (#2541)

* Add flag to enable trust per repo

* Add api for enabling/disabling trust

* Add new LogEntryKind for changing repo trust settings
Also add tests for repo trust api

* Add `set_trust` method to repository

* Expose new logkind to UI

* Fix registry tests

* Rebase migrations and regen test.db

* Raise downstreamissue if trust metadata can't be removed

* Refactor change_repo_trust

* Add show_if to change_repo_trust endpoint
This commit is contained in:
Evan Cordell 2017-04-15 08:26:33 -04:00 committed by GitHub
parent aa1c8d47dd
commit 2661db7485
13 changed files with 176 additions and 12 deletions

View file

@ -568,6 +568,7 @@ class Repository(BaseModel):
description = FullIndexedTextField(match_function=db_match_func, null=True) description = FullIndexedTextField(match_function=db_match_func, null=True)
badge_token = CharField(default=uuid_generator) badge_token = CharField(default=uuid_generator)
kind = EnumField(RepositoryKind) kind = EnumField(RepositoryKind)
trust_enabled = BooleanField(default=False)
class Meta: class Meta:
database = db database = db

View file

@ -13,7 +13,7 @@ _MEDIA_TYPE = "application/vnd.docker.distribution.manifest.v1+prettyjws"
class Repository(namedtuple('Repository', ['id', 'name', 'namespace_name', 'description', class Repository(namedtuple('Repository', ['id', 'name', 'namespace_name', 'description',
'is_public', 'kind'])): 'is_public', 'kind', 'trust_enabled'])):
""" """
Repository represents a namespaced collection of tags. Repository represents a namespaced collection of tags.
:type id: int :type id: int
@ -22,6 +22,7 @@ class Repository(namedtuple('Repository', ['id', 'name', 'namespace_name', 'desc
:type description: string :type description: string
:type is_public: bool :type is_public: bool
:type kind: string :type kind: string
:type trust_enabled: bool
""" """
class ManifestJSON(namedtuple('ManifestJSON', ['digest', 'json', 'media_type'])): class ManifestJSON(namedtuple('ManifestJSON', ['digest', 'json', 'media_type'])):
@ -536,6 +537,7 @@ def _repository_for_repo(repo):
description=repo.description, description=repo.description,
is_public=model.repository.is_repository_public(repo), is_public=model.repository.is_repository_public(repo),
kind=model.repository.get_repo_kind_name(repo), kind=model.repository.get_repo_kind_name(repo),
trust_enabled=repo.trust_enabled,
) )

View file

@ -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')))

View file

@ -279,6 +279,11 @@ def unstar_repository(user, repository):
.execute()) .execute())
except Star.DoesNotExist: except Star.DoesNotExist:
raise DataModelException('Star not found.') 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'): def get_user_starred_repositories(user, kind_filter='image'):

View file

@ -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, require_repo_read, require_repo_write, require_repo_admin,
RepositoryParamResource, resource, query_param, parse_args, ApiResource, RepositoryParamResource, resource, query_param, parse_args, ApiResource,
request_error, require_scope, path_param, page_support, parse_args, request_error, require_scope, path_param, page_support, parse_args,
query_param, truthy_bool, disallow_for_app_repositories) query_param, truthy_bool, disallow_for_app_repositories, show_if)
from endpoints.exception import Unauthorized, NotFound, InvalidRequest, ExceedsLicenseException from endpoints.exception import Unauthorized, NotFound, InvalidRequest, ExceedsLicenseException, DownstreamIssue
from endpoints.api.billing import lookup_allowed_private_repos, get_namespace_plan from endpoints.api.billing import lookup_allowed_private_repos, get_namespace_plan
from endpoints.api.subscribe import check_repository_usage from endpoints.api.subscribe import check_repository_usage
@ -377,6 +377,7 @@ class Repository(RepositoryParamResource):
'is_organization': repo.namespace_user.organization, 'is_organization': repo.namespace_user.organization,
'is_starred': is_starred, 'is_starred': is_starred,
'status_token': repo.badge_token if not is_public else '', 'status_token': repo.badge_token if not is_public else '',
'trust_enabled': repo.trust_enabled,
} }
if stats is not None: if stats is not None:
@ -464,3 +465,46 @@ class RepositoryVisibility(RepositoryParamResource):
{'repo': repository, 'namespace': namespace, 'visibility': values['visibility']}, {'repo': repository, 'namespace': namespace, 'visibility': values['visibility']},
repo=repo) repo=repo)
return {'success': True} return {'success': True}
@resource('/v1/repository/<apirepopath: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}

View file

@ -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/<apirepopath:repository>/changetrust or /api/v1/repository/<apirepopath:repository>/changevisibility or /api/v1/repository/<apirepopath:repository>/tag/<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

View file

@ -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 SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
from endpoints.api.superuser import SuperUserRepositoryBuildStatus from endpoints.api.superuser import SuperUserRepositoryBuildStatus
from endpoints.api.signing import RepositorySignatures 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 from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'} 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, {}, 'freshuser', 403),
(RepositorySignatures, 'GET', REPO_PARAMS, {}, 'reader', 403), (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'reader', 403),
(RepositorySignatures, 'GET', REPO_PARAMS, {}, 'devtable', 200), (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): def test_api_security(resource, method, params, body, identity, expected, client):
with client_with_identity(identity, client) as cl: with client_with_identity(identity, client) as cl:

View file

@ -3,10 +3,14 @@ import pytest
import flask import flask
from flask_principal import Identity, Principal 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 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): def admin_identity(namespace, reponame):
identity = Identity('admin') identity = Identity('admin')
identity.provides.add(permissions._RepositoryNeed(namespace, reponame, 'admin')) identity.provides.add(permissions._RepositoryNeed(namespace, reponame, 'admin'))
@ -27,7 +31,7 @@ def read_identity(namespace, reponame):
def app_with_principal(): def app_with_principal():
app = flask.Flask(__name__) app = flask.Flask(__name__)
app.config.update(SECRET_KEY='secret', TESTING=True) app.config.from_object(testconfig.TestConfig())
principal = Principal(app) principal = Principal(app)
return app, principal return app, principal
@ -44,5 +48,17 @@ 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("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)
@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)

View file

@ -4,6 +4,7 @@ import re
from cachetools import lru_cache from cachetools import lru_cache
from flask import request, jsonify, abort from flask import request, jsonify, abort
import features
from app import app, userevents, instance_keys from app import app, userevents, instance_keys
from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token
from auth.decorators import process_basic_auth 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 data.interfaces.v2 import pre_oci_model as model
from util.cache import no_cache from util.cache import no_cache
from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX 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__) logger = logging.getLogger(__name__)
@ -64,7 +66,7 @@ def generate_registry_jwt(auth_result):
user_event_data = { user_event_data = {
'action': 'login', 'action': 'login',
} }
tuf_root = QUAY_TUF_ROOT tuf_root = DISABLED_TUF_ROOT
if len(scope_param) > 0: if len(scope_param) > 0:
match = get_scope_regex().match(scope_param) match = get_scope_regex().match(scope_param)
@ -164,8 +166,7 @@ def generate_registry_jwt(auth_result):
'repository': reponame, 'repository': reponame,
'namespace': namespace, 'namespace': namespace,
} }
tuf_root = get_tuf_root(repo, namespace, reponame)
tuf_root = get_tuf_root(namespace, reponame)
elif user is None and token is None: elif user is None and token is None:
# In this case, we are doing an auth flow, and it's not an anonymous pull # 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}) 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 # Users with write access to a repo will see signer-rooted TUF metadata
if ModifyRepositoryPermission(namespace, reponame).can(): if ModifyRepositoryPermission(namespace, reponame).can():
return SIGNER_TUF_ROOT return SIGNER_TUF_ROOT

View file

@ -296,6 +296,7 @@ def initialize_database():
LogEntryKind.create(name='change_repo_permission') LogEntryKind.create(name='change_repo_permission')
LogEntryKind.create(name='delete_repo_permission') LogEntryKind.create(name='delete_repo_permission')
LogEntryKind.create(name='change_repo_visibility') LogEntryKind.create(name='change_repo_visibility')
LogEntryKind.create(name='change_repo_trust')
LogEntryKind.create(name='add_repo_accesstoken') LogEntryKind.create(name='add_repo_accesstoken')
LogEntryKind.create(name='delete_repo_accesstoken') LogEntryKind.create(name='delete_repo_accesstoken')
LogEntryKind.create(name='set_repo_description') LogEntryKind.create(name='set_repo_description')

View file

@ -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}', '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}', '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_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}', 'add_repo_accesstoken': 'Create access token {token} in repository {repo}',
'delete_repo_accesstoken': 'Delete 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}', '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', 'change_repo_permission': 'Change repository permission',
'delete_repo_permission': 'Remove user permission from repository', 'delete_repo_permission': 'Remove user permission from repository',
'change_repo_visibility': 'Change repository visibility', 'change_repo_visibility': 'Change repository visibility',
'change_repo_trust': 'Change repository trust settings',
'add_repo_accesstoken': 'Create access token', 'add_repo_accesstoken': 'Create access token',
'delete_repo_accesstoken': 'Delete access token', 'delete_repo_accesstoken': 'Delete access token',
'set_repo_description': 'Change repository description', 'set_repo_description': 'Change repository description',

Binary file not shown.

View file

@ -11,6 +11,7 @@ ALGORITHM = 'RS256'
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'
DISABLED_TUF_ROOT = '$disabled'
# The number of allowed seconds of clock skew for a JWT. The iat, nbf and exp are adjusted with this # The number of allowed seconds of clock skew for a JWT. The iat, nbf and exp are adjusted with this
# count. # count.