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:
parent
aa1c8d47dd
commit
2661db7485
13 changed files with 176 additions and 12 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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')))
|
|
@ -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'):
|
||||
|
|
|
@ -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}
|
||||
|
|
42
endpoints/api/test/test_repository.py
Normal file
42
endpoints/api/test/test_repository.py
Normal 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
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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.
|
@ -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.
|
||||
|
|
Reference in a new issue