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)
badge_token = CharField(default=uuid_generator)
kind = EnumField(RepositoryKind)
trust_enabled = BooleanField(default=False)
class Meta:
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',
'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,
)

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())
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'):

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,
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/<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 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:

View file

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

View file

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

View file

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

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}',
'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',

Binary file not shown.

View file

@ -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.