Merge pull request #2447 from jzelinskie/cnr-step2

CNR Step 2
This commit is contained in:
Jimmy Zelinskie 2017-03-22 18:45:51 -04:00 committed by GitHub
commit 3ccf3c5f33
40 changed files with 790 additions and 136 deletions

View file

@ -341,7 +341,6 @@ class QuayUserField(ForeignKeyField):
super(QuayUserField, self).__init__(*args, **kwargs)
# @TODO: Generates client-side enum
class EnumField(ForeignKeyField):
""" Create a cached python Enum from an EnumTable """
def __init__(self, rel_model, enum_key_field='name', *args, **kwargs):
@ -549,12 +548,17 @@ class Visibility(BaseModel):
name = CharField(index=True, unique=True)
class RepositoryKind(BaseModel):
name = CharField(index=True, unique=True)
class Repository(BaseModel):
namespace_user = QuayUserField(null=True)
name = FullIndexedCharField(match_function=db_match_func)
visibility = ForeignKeyField(Visibility)
description = FullIndexedTextField(match_function=db_match_func, null=True)
badge_token = CharField(default=uuid_generator)
kind = EnumField(RepositoryKind)
class Meta:
database = db

View file

@ -10,9 +10,15 @@ from util.morecollections import AttrDict
class Repository(namedtuple('Repository', ['id', 'name', 'namespace_name', 'description',
'is_public'])):
'is_public', 'kind'])):
"""
Repository represents a namespaced collection of tags.
:type id: int
:type name: string
:type namespace_name: string
:type description: string
:type is_public: bool
:type kind: string
"""
@ -383,8 +389,9 @@ class PreOCIModel(DockerRegistryV1DataInterface):
return bool(model.oauth.validate_access_token(token))
def get_sorted_matching_repositories(self, search_term, filter_username=None, offset=0, limit=25):
repos = model.repository.get_filtered_matching_repositories(search_term, filter_username,
offset, limit)
repos = model.repository.get_filtered_matching_repositories(search_term,
filter_username=filter_username,
offset=offset, limit=limit)
return [_repository_for_repo(repo) for repo in repos]
@ -395,7 +402,8 @@ def _repository_for_repo(repo):
name=repo.name,
namespace_name=repo.namespace_user.username,
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),
)

View file

@ -13,9 +13,15 @@ _MEDIA_TYPE = "application/vnd.docker.distribution.manifest.v1+prettyjws"
class Repository(namedtuple('Repository', ['id', 'name', 'namespace_name', 'description',
'is_public'])):
'is_public', 'kind'])):
"""
Repository represents a namespaced collection of tags.
:type id: int
:type name: string
:type namespace_name: string
:type description: string
:type is_public: bool
:type kind: string
"""
class ManifestJSON(namedtuple('ManifestJSON', ['digest', 'json', 'media_type'])):
@ -70,14 +76,6 @@ class DockerRegistryV2DataInterface(object):
"""
pass
@abstractmethod
def repository_is_public(self, namespace_name, repo_name):
"""
Returns true if the repository with the given name under the given namespace has public
visibility.
"""
pass
@abstractmethod
def get_repository(self, namespace_name, repo_name):
"""
@ -271,9 +269,6 @@ class PreOCIModel(DockerRegistryV2DataInterface):
def create_repository(self, namespace_name, repo_name, creating_user=None):
return model.repository.create_repository(namespace_name, repo_name, creating_user)
def repository_is_public(self, namespace_name, repo_name):
return model.repository.repository_is_public(namespace_name, repo_name)
def get_repository(self, namespace_name, repo_name):
repo = model.repository.get_repository(namespace_name, repo_name)
if repo is None:
@ -392,7 +387,8 @@ class PreOCIModel(DockerRegistryV2DataInterface):
return [_tag_view(tag) for tag in tags_query]
def get_visible_repositories(self, username, limit, offset):
query = model.repository.get_visible_repositories(username, include_public=(username is None))
query = model.repository.get_visible_repositories(username, repo_kind='image',
include_public=(username is None))
query = query.limit(limit).offset(offset)
return [_repository_for_repo(repo) for repo in query]
@ -538,7 +534,8 @@ def _repository_for_repo(repo):
name=repo.name,
namespace_name=repo.namespace_user.username,
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),
)

View file

@ -9,6 +9,19 @@ from data import model
from image.docker.v1 import DockerV1Metadata
class Repository(namedtuple('Repository', ['id', 'name', 'namespace_name', 'description',
'is_public', 'kind'])):
"""
Repository represents a namespaced collection of tags.
:type id: int
:type name: string
:type namespace_name: string
:type description: string
:type is_public: bool
:type kind: string
"""
class DerivedImage(namedtuple('DerivedImage', ['ref', 'blob', 'internal_source_image_db_id'])):
"""
DerivedImage represents a user-facing alias for an image which was derived from another image.
@ -43,9 +56,10 @@ class VerbsDataInterface(object):
verbs.
"""
@abstractmethod
def repository_is_public(self, namespace_name, repo_name):
def get_repository(self, namespace_name, repo_name):
"""
Returns a boolean for whether the repository with the given name and namespace is public.
Returns a repository tuple for the repository with the given name under the given namespace.
Returns None if no such repository was found.
"""
pass
@ -144,8 +158,12 @@ class PreOCIModel(VerbsDataInterface):
before it was changed to support the OCI specification.
"""
def repository_is_public(self, namespace_name, repo_name):
return model.repository.repository_is_public(namespace_name, repo_name)
def get_repository(self, namespace_name, repo_name):
repo = model.repository.get_repository(namespace_name, repo_name)
if repo is None:
return None
return _repository_for_repo(repo)
def get_manifest_layers_with_blobs(self, repo_image):
repo_image_record = model.image.get_image_by_id(repo_image.repository.namespace_name,
@ -320,3 +338,14 @@ def _blob(blob_record):
uploading=blob_record.uploading,
locations=locations,
)
def _repository_for_repo(repo):
""" Returns a Repository object representing the Pre-OCI data model repo instance given. """
return Repository(
id=repo.id,
name=repo.name,
namespace_name=repo.namespace_user.username,
description=repo.description,
is_public=model.repository.is_repository_public(repo),
kind=model.repository.get_repo_kind_name(repo),
)

View file

@ -10,9 +10,9 @@ up_mysql() {
# Run a SQL database on port 3306 inside of Docker.
docker run --name mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password -d mysql
# Sleep for 10s to get MySQL get started.
echo 'Sleeping for 20...'
sleep 20
# Sleep for 25s to get MySQL get started.
echo 'Sleeping for 25...'
sleep 25
# Add the database to mysql.
docker run --rm --link mysql:mysql mysql sh -c 'echo "create database genschema" | mysql -h"$MYSQL_PORT_3306_TCP_ADDR" -P"$MYSQL_PORT_3306_TCP_PORT" -uroot -ppassword'
@ -27,9 +27,9 @@ up_mariadb() {
# Run a SQL database on port 3306 inside of Docker.
docker run --name mariadb -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password -d mariadb
# Sleep for 20s to get MySQL get started.
echo 'Sleeping for 20...'
sleep 20
# Sleep for 25s to get MySQL get started.
echo 'Sleeping for 25...'
sleep 25
# Add the database to mysql.
docker run --rm --link mariadb:mariadb mariadb sh -c 'echo "create database genschema" | mysql -h"$MARIADB_PORT_3306_TCP_ADDR" -P"$MARIADB_PORT_3306_TCP_PORT" -uroot -ppassword'
@ -45,8 +45,8 @@ up_percona() {
docker run --name percona -p 3306:3306 -e MYSQL_ROOT_PASSWORD=password -d percona
# Sleep for 20s
echo 'Sleeping for 20...'
sleep 20
echo 'Sleeping for 25...'
sleep 25
# Add the daabase to mysql.
docker run --rm --link percona:percona percona sh -c 'echo "create database genschema" | mysql -h $PERCONA_PORT_3306_TCP_ADDR -uroot -ppassword'

View file

@ -300,9 +300,9 @@ def upgrade(tables):
op.bulk_insert(
tables.tagkind,
[
{'name': 'tag', 'id': 1},
{'name': 'release', 'id': 2},
{'name': 'channel', 'id': 3},
{'id': 1, 'name': 'tag'},
{'id': 2, 'name': 'release'},
{'id': 3, 'name': 'channel'},
]
)

View file

@ -0,0 +1,44 @@
"""add repository kind
Revision ID: b4df55dea4b3
Revises: 7a525c68eb13
Create Date: 2017-03-19 12:59:41.484430
"""
# revision identifiers, used by Alembic.
revision = 'b4df55dea4b3'
down_revision = 'b8ae68ad3e52'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
def upgrade(tables):
op.create_table(
'repositorykind',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_repositorykind'))
)
op.create_index('repositorykind_name', 'repositorykind', ['name'], unique=True)
op.bulk_insert(
tables.repositorykind,
[
{'id': 1, 'name': 'image'},
{'id': 2, 'name': 'application'},
],
)
op.add_column(u'repository', sa.Column('kind_id', sa.Integer(), nullable=False, server_default='1'))
op.create_index('repository_kind_id', 'repository', ['kind_id'], unique=False)
op.create_foreign_key(op.f('fk_repository_kind_id_repositorykind'), 'repository', 'repositorykind', ['kind_id'], ['id'])
def downgrade(tables):
op.drop_constraint(op.f('fk_repository_kind_id_repositorykind'), 'repository', type_='foreignkey')
op.drop_index('repository_kind_id', table_name='repository')
op.drop_column(u'repository', 'kind_id')
op.drop_table('repositorykind')

View file

@ -3,14 +3,23 @@ from cachetools import lru_cache
from data.model import DataModelException
from data.database import (Repository, User, Team, TeamMember, RepositoryPermission, TeamRole,
Namespace, Visibility, ImageStorage, Image, db_for_update)
Namespace, Visibility, ImageStorage, Image, RepositoryKind,
db_for_update)
def get_existing_repository(namespace_name, repository_name, for_update=False):
def get_existing_repository(namespace_name, repository_name, for_update=False, kind_filter=None):
query = (Repository
.select(Repository, Namespace)
.join(Namespace, on=(Repository.namespace_user == Namespace.id))
.where(Namespace.username == namespace_name, Repository.name == repository_name))
.where(Namespace.username == namespace_name,
Repository.name == repository_name))
if kind_filter:
query = (query
.switch(Repository)
.join(RepositoryKind)
.where(RepositoryKind.name == kind_filter))
if for_update:
query = db_for_update(query)
@ -27,11 +36,14 @@ def _lookup_team_role(name):
return TeamRole.get(name=name)
def filter_to_repos_for_user(query, username=None, namespace=None, include_public=True,
start_id=None):
def filter_to_repos_for_user(query, username=None, namespace=None, repo_kind='image',
include_public=True, start_id=None):
if not include_public and not username:
return Repository.select().where(Repository.id == '-1')
# Filter on the type of repository.
query = query.where(Repository.kind == Repository.kind.get_id(repo_kind))
# Add the start ID if necessary.
if start_id is not None:
query = query.where(Repository.id >= start_id)
@ -121,5 +133,3 @@ def calculate_image_aggregate_size(ancestors_str, image_size, parent_image):
return None
return ancestor_size + image_size

View file

@ -18,6 +18,11 @@ from util.itertoolrecipes import take
logger = logging.getLogger(__name__)
def get_repo_kind_name(repo):
return Repository.kind.get_name(repo.kind_id)
def get_repository_count():
return Repository.select().count()
@ -26,10 +31,11 @@ def get_public_repo_visibility():
return _basequery.get_public_repo_visibility()
def create_repository(namespace, name, creating_user, visibility='private'):
def create_repository(namespace, name, creating_user, visibility='private', repo_kind='image'):
private = Visibility.get(name=visibility)
namespace_user = User.get(username=namespace)
repo = Repository.create(name=name, visibility=private, namespace_user=namespace_user)
repo = Repository.create(name=name, visibility=private, namespace_user=namespace_user,
kind=Repository.kind.get_id(repo_kind))
admin = Role.get(name='admin')
yesterday = datetime.now() - timedelta(days=1)
@ -44,9 +50,10 @@ def create_repository(namespace, name, creating_user, visibility='private'):
return repo
def get_repository(namespace_name, repository_name):
def get_repository(namespace_name, repository_name, kind_filter=None):
try:
return _basequery.get_existing_repository(namespace_name, repository_name)
return _basequery.get_existing_repository(namespace_name, repository_name,
kind_filter=kind_filter)
except Repository.DoesNotExist:
return None
@ -103,7 +110,7 @@ def _get_gc_expiration_policies():
def get_random_gc_policy():
""" Return a single random policy from the database to use when garbage collecting.
"""
"""
return random.choice(_get_gc_expiration_policies())
@ -259,7 +266,7 @@ def unstar_repository(user, repository):
raise DataModelException('Star not found.')
def get_user_starred_repositories(user):
def get_user_starred_repositories(user, repo_kind='image'):
""" Retrieves all of the repositories a user has starred. """
query = (Repository
.select(Repository, User, Visibility, Repository.id.alias('rid'))
@ -268,7 +275,8 @@ def get_user_starred_repositories(user):
.join(User)
.switch(Repository)
.join(Visibility)
.where(Star.user == user))
.where(Star.user == user,
Repository.kind == Repository.kind.get_id(repo_kind)))
return query
@ -302,8 +310,8 @@ def get_when_last_modified(repository_ids):
return last_modified_map
def get_visible_repositories(username, namespace=None, include_public=False, start_id=None,
limit=None):
def get_visible_repositories(username, namespace=None, repo_kind='image', include_public=False,
start_id=None, limit=None):
""" Returns the repositories visible to the given user (if any).
"""
if not include_public and not username:
@ -313,7 +321,8 @@ def get_visible_repositories(username, namespace=None, include_public=False, sta
query = (Repository
.select(Repository.name, Repository.id.alias('rid'),
Repository.description, Namespace.username, Repository.visibility)
Repository.description, Namespace.username, Repository.visibility,
Repository.kind)
.switch(Repository)
.join(Namespace, on=(Repository.namespace_user == Namespace.id)))
@ -321,7 +330,7 @@ def get_visible_repositories(username, namespace=None, include_public=False, sta
# Note: We only need the permissions table if we will filter based on a user's permissions.
query = query.switch(Repository).distinct().join(RepositoryPermission, JOIN_LEFT_OUTER)
query = _basequery.filter_to_repos_for_user(query, username, namespace, include_public,
query = _basequery.filter_to_repos_for_user(query, username, namespace, repo_kind, include_public,
start_id=start_id)
if limit is not None:
@ -330,14 +339,15 @@ def get_visible_repositories(username, namespace=None, include_public=False, sta
return query
def get_filtered_matching_repositories(lookup_value, filter_username=None, offset=0, limit=25):
def get_filtered_matching_repositories(lookup_value, filter_username=None, repo_kind='image',
offset=0, limit=25):
""" Returns an iterator of all repositories matching the given lookup value, with optional
filtering to a specific user. If the user is unspecified, only public repositories will
be returned.
"""
# Build the unfiltered search query.
unfiltered_query = _get_sorted_matching_repositories(lookup_value,
unfiltered_query = _get_sorted_matching_repositories(lookup_value, repo_kind=repo_kind,
include_private=filter_username is not None)
# Add a filter to the iterator, if necessary.
@ -395,7 +405,7 @@ def _filter_repositories_visible_to_username(unfiltered_query, filter_username,
iteration_count = iteration_count + 1
def _get_sorted_matching_repositories(lookup_value, include_private=False):
def _get_sorted_matching_repositories(lookup_value, repo_kind='image', include_private=False):
""" Returns a query of repositories matching the given lookup string, with optional inclusion of
private repositories. Note that this method does *not* filter results based on visibility
to users.
@ -405,7 +415,8 @@ def _get_sorted_matching_repositories(lookup_value, include_private=False):
query = (Repository
.select(Repository, Namespace)
.join(Namespace, on=(Namespace.id == Repository.namespace_user))
.where(Repository.name.match(lookup_value) | Repository.description.match(lookup_value))
.where(Repository.name.match(lookup_value) | Repository.description.match(lookup_value),
Repository.kind == Repository.kind.get_id(repo_kind))
.group_by(Repository.id, Namespace.id))
if not include_private:
@ -438,7 +449,8 @@ def repository_is_public(namespace_name, repository_name):
.join(Namespace, on=(Repository.namespace_user == Namespace.id))
.switch(Repository)
.join(Visibility)
.where(Namespace.username == namespace_name, Repository.name == repository_name,
.where(Namespace.username == namespace_name,
Repository.name == repository_name,
Visibility.name == 'public')
.get())
return True
@ -461,7 +473,8 @@ def get_email_authorized_for_repo(namespace, repository, email):
.select(RepositoryAuthorizedEmail, Repository, Namespace)
.join(Repository)
.join(Namespace, on=(Repository.namespace_user == Namespace.id))
.where(Namespace.username == namespace, Repository.name == repository,
.where(Namespace.username == namespace,
Repository.name == repository,
RepositoryAuthorizedEmail.email == email)
.get())
except RepositoryAuthorizedEmail.DoesNotExist:
@ -495,7 +508,7 @@ def confirm_email_authorization_for_repo(code):
return found
def list_popular_public_repos(action_count_threshold, time_span):
def list_popular_public_repos(action_count_threshold, time_span, repo_kind='image'):
cutoff = datetime.now() - time_span
return (Repository
.select(Namespace.username, Repository.name)
@ -503,7 +516,8 @@ def list_popular_public_repos(action_count_threshold, time_span):
.switch(Repository)
.join(RepositoryActionCount)
.where(RepositoryActionCount.date >= cutoff,
Repository.visibility == get_public_repo_visibility())
Repository.visibility == get_public_repo_visibility(),
Repository.kind == Repository.kind.get_id(repo_kind))
.group_by(RepositoryActionCount.repository, Repository.name, Namespace.username)
.having(fn.Sum(RepositoryActionCount.count) >= action_count_threshold)
.tuples())

View file

@ -0,0 +1,14 @@
import pytest
from peewee import IntegrityError
from endpoints.test.fixtures import database_uri, init_db_path, sqlitedb_file
from data.model.repository import create_repository
def test_duplicate_repository_different_kinds(database_uri):
# Create an image repo.
create_repository('devtable', 'somenewrepo', None, repo_kind='image')
# Try to create an app repo with the same name, which should fail.
with pytest.raises(IntegrityError):
create_repository('devtable', 'somenewrepo', None, repo_kind='application')

View file

@ -22,7 +22,7 @@ from auth.auth_context import get_authenticated_user, get_validated_oauth_token
from auth.process import process_oauth
from endpoints.csrf import csrf_protect
from endpoints.exception import (ApiException, Unauthorized, InvalidRequest, InvalidResponse,
FreshLoginRequired)
FreshLoginRequired, NotFound)
from endpoints.decorators import check_anon_protection
from util.metrics.metricqueue import time_decorator
from util.names import parse_namespace_repository
@ -200,6 +200,20 @@ class RepositoryParamResource(ApiResource):
method_decorators = [check_anon_protection, parse_repository_name]
def disallow_for_app_repositories(func):
@wraps(func)
def wrapped(self, namespace, repository, *args, **kwargs):
# Lookup the repository with the given namespace and name and ensure it is not an application
# repository.
repo = model.repository.get_repository(namespace, repository, kind_filter='application')
if repo:
abort(501)
return func(self, namespace, repository, *args, **kwargs)
return wrapped
def require_repo_permission(permission_class, scope, allow_public=False):
def wrapper(func):
@add_method_metadata('oauth2_scope', scope)

View file

@ -14,7 +14,7 @@ from buildtrigger.basehandler import BuildTriggerHandler
from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource,
require_repo_read, require_repo_write, validate_json_request,
ApiResource, internal_only, format_date, api, path_param,
require_repo_admin, abort)
require_repo_admin, abort, disallow_for_app_repositories)
from endpoints.exception import Unauthorized, NotFound, InvalidRequest
from endpoints.building import start_build, PreparedBuild, MaximumBuildsQueuedException
from data import database
@ -200,6 +200,7 @@ class RepositoryBuildList(RepositoryParamResource):
@query_param('limit', 'The maximum number of builds to return', type=int, default=5)
@query_param('since', 'Returns all builds since the given unix timecode', type=int, default=None)
@nickname('getRepoBuilds')
@disallow_for_app_repositories
def get(self, namespace, repository, parsed_args):
""" Get the list of repository builds. """
limit = parsed_args.get('limit', 5)
@ -215,6 +216,7 @@ class RepositoryBuildList(RepositoryParamResource):
@require_repo_write
@nickname('requestRepoBuild')
@disallow_for_app_repositories
@validate_json_request('RepositoryBuildRequest')
def post(self, namespace, repository):
""" Request that a repository be built and pushed from the specified input. """
@ -315,6 +317,7 @@ class RepositoryBuildResource(RepositoryParamResource):
""" Resource for dealing with repository builds. """
@require_repo_read
@nickname('getRepoBuild')
@disallow_for_app_repositories
def get(self, namespace, repository, build_uuid):
""" Returns information about a build. """
try:
@ -329,6 +332,7 @@ class RepositoryBuildResource(RepositoryParamResource):
@require_repo_admin
@nickname('cancelRepoBuild')
@disallow_for_app_repositories
def delete(self, namespace, repository, build_uuid):
""" Cancels a repository build. """
try:
@ -352,6 +356,7 @@ class RepositoryBuildStatus(RepositoryParamResource):
""" Resource for dealing with repository build status. """
@require_repo_read
@nickname('getRepoBuildStatus')
@disallow_for_app_repositories
def get(self, namespace, repository, build_uuid):
""" Return the status for the builds specified by the build uuids. """
build = model.build.get_repository_build(build_uuid)
@ -392,6 +397,7 @@ class RepositoryBuildLogs(RepositoryParamResource):
""" Resource for loading repository build logs. """
@require_repo_write
@nickname('getRepoBuildLogs')
@disallow_for_app_repositories
def get(self, namespace, repository, build_uuid):
""" Return the build logs for the build specified by the build uuid. """
build = model.build.get_repository_build(build_uuid)

View file

@ -4,7 +4,7 @@ import json
from collections import defaultdict
from endpoints.api import (resource, nickname, require_repo_read, RepositoryParamResource,
format_date, path_param)
format_date, path_param, disallow_for_app_repositories)
from endpoints.exception import NotFound
from data import model
@ -49,6 +49,7 @@ class RepositoryImageList(RepositoryParamResource):
""" Resource for listing repository images. """
@require_repo_read
@nickname('listRepositoryImages')
@disallow_for_app_repositories
def get(self, namespace, repository):
""" List the images for the specified repository. """
repo = model.repository.get_repository(namespace, repository)
@ -89,6 +90,7 @@ class RepositoryImage(RepositoryParamResource):
""" Resource for handling repository images. """
@require_repo_read
@nickname('getImage')
@disallow_for_app_repositories
def get(self, namespace, repository, image_id):
""" Get the information available for the specified image. """
image = model.image.get_repo_image_extended(namespace, repository, image_id)

View file

@ -4,7 +4,8 @@ from app import label_validator
from flask import request
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
RepositoryParamResource, log_action, validate_json_request,
path_param, parse_args, query_param, truthy_bool, abort, api)
path_param, parse_args, query_param, truthy_bool, abort, api,
disallow_for_app_repositories)
from endpoints.exception import NotFound
from data import model
@ -59,6 +60,7 @@ class RepositoryManifestLabels(RepositoryParamResource):
@require_repo_read
@nickname('listManifestLabels')
@disallow_for_app_repositories
@parse_args()
@query_param('filter', 'If specified, only labels matching the given prefix will be returned',
type=str, default=None)
@ -75,6 +77,7 @@ class RepositoryManifestLabels(RepositoryParamResource):
@require_repo_write
@nickname('addManifestLabel')
@disallow_for_app_repositories
@validate_json_request('AddLabel')
def post(self, namespace, repository, manifestref):
""" Adds a new label into the tag manifest. """
@ -121,6 +124,7 @@ class ManageRepositoryManifestLabel(RepositoryParamResource):
""" Resource for managing the labels on a specific repository manifest. """
@require_repo_read
@nickname('getManifestLabel')
@disallow_for_app_repositories
def get(self, namespace, repository, manifestref, labelid):
""" Retrieves the label with the specific ID under the manifest. """
try:
@ -137,6 +141,7 @@ class ManageRepositoryManifestLabel(RepositoryParamResource):
@require_repo_write
@nickname('deleteManifestLabel')
@disallow_for_app_repositories
def delete(self, namespace, repository, manifestref, labelid):
""" Deletes an existing label from a manifest. """
try:

View file

@ -14,7 +14,7 @@ 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)
query_param, truthy_bool, disallow_for_app_repositories)
from endpoints.exception import Unauthorized, NotFound, InvalidRequest, ExceedsLicenseException
from endpoints.api.billing import lookup_allowed_private_repos, get_namespace_plan
from endpoints.api.subscribe import check_repository_usage
@ -77,6 +77,11 @@ class RepositoryList(ApiResource):
'type': 'string',
'description': 'Markdown encoded description for the repository',
},
'kind': {
'type': 'string',
'description': 'The kind of repository',
'enum': ['image', 'application'],
}
},
},
}
@ -111,7 +116,9 @@ class RepositoryList(ApiResource):
if not REPOSITORY_NAME_REGEX.match(repository_name):
raise InvalidRequest('Invalid repository name')
repo = model.repository.create_repository(namespace_name, repository_name, owner, visibility)
kind = req.get('kind', 'image')
repo = model.repository.create_repository(namespace_name, repository_name, owner, visibility,
repo_kind=kind)
repo.description = req['description']
repo.save()
@ -354,6 +361,7 @@ class Repository(RepositoryParamResource):
@require_repo_admin
@nickname('deleteRepository')
@disallow_for_app_repositories
def delete(self, namespace, repository):
""" Delete a repository. """
model.repository.purge_repository(namespace, repository)

View file

@ -7,7 +7,7 @@ from flask import request
from app import notification_queue
from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin,
log_action, validate_json_request, request_error,
path_param)
path_param, disallow_for_app_repositories)
from endpoints.exception import NotFound
from endpoints.notificationevent import NotificationEvent
from endpoints.notificationmethod import (NotificationMethod,
@ -80,6 +80,7 @@ class RepositoryNotificationList(RepositoryParamResource):
@require_repo_admin
@nickname('createRepoNotification')
@disallow_for_app_repositories
@validate_json_request('NotificationCreateRequest')
def post(self, namespace, repository):
""" Create a new notification for the specified repository. """
@ -110,6 +111,7 @@ class RepositoryNotificationList(RepositoryParamResource):
@require_repo_admin
@nickname('listRepoNotifications')
@disallow_for_app_repositories
def get(self, namespace, repository):
""" List the notifications for the specified repository. """
notifications = model.notification.list_repo_notifications(namespace, repository)
@ -125,6 +127,7 @@ class RepositoryNotification(RepositoryParamResource):
""" Resource for dealing with specific notifications. """
@require_repo_admin
@nickname('getRepoNotification')
@disallow_for_app_repositories
def get(self, namespace, repository, uuid):
""" Get information for the specified notification. """
try:
@ -140,6 +143,7 @@ class RepositoryNotification(RepositoryParamResource):
@require_repo_admin
@nickname('deleteRepoNotification')
@disallow_for_app_repositories
def delete(self, namespace, repository, uuid):
""" Deletes the specified notification. """
deleted = model.notification.delete_repo_notification(namespace, repository, uuid)
@ -158,6 +162,7 @@ class TestRepositoryNotification(RepositoryParamResource):
""" Resource for queuing a test of a notification. """
@require_repo_admin
@nickname('testRepoNotification')
@disallow_for_app_repositories
def post(self, namespace, repository, uuid):
""" Queues a test notification for this repository. """
try:

View file

@ -7,7 +7,7 @@ from app import secscan_api
from data import model
from endpoints.api import (require_repo_read, path_param,
RepositoryParamResource, resource, nickname, show_if, parse_args,
query_param, truthy_bool)
query_param, truthy_bool, disallow_for_app_repositories)
from endpoints.exception import NotFound, DownstreamIssue
from endpoints.api.manifest import MANIFEST_DIGEST_ROUTE
from util.secscan.api import APIRequestFailure
@ -67,6 +67,7 @@ class RepositoryImageSecurity(RepositoryParamResource):
@require_repo_read
@nickname('getRepoImageSecurity')
@disallow_for_app_repositories
@parse_args()
@query_param('vulnerabilities', 'Include vulnerabilities informations', type=truthy_bool,
default=False)
@ -88,6 +89,7 @@ class RepositoryManifestSecurity(RepositoryParamResource):
@require_repo_read
@nickname('getRepoManifestSecurity')
@disallow_for_app_repositories
@parse_args()
@query_param('vulnerabilities', 'Include vulnerabilities informations', type=truthy_bool,
default=False)

View file

@ -4,7 +4,8 @@ from flask import request, abort
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
RepositoryParamResource, log_action, validate_json_request,
path_param, parse_args, query_param, truthy_bool)
path_param, parse_args, query_param, truthy_bool,
disallow_for_app_repositories)
from endpoints.exception import NotFound
from endpoints.api.image import image_view
from data import model
@ -18,6 +19,7 @@ class ListRepositoryTags(RepositoryParamResource):
""" Resource for listing full repository tag history, alive *and dead*. """
@require_repo_read
@disallow_for_app_repositories
@parse_args()
@query_param('specificTag', 'Filters the tags to the specific tag.', type=str, default='')
@query_param('limit', 'Limit to the number of results to return per page. Max 100.', type=int, default=50)
@ -82,6 +84,7 @@ class RepositoryTag(RepositoryParamResource):
}
@require_repo_write
@disallow_for_app_repositories
@nickname('changeTagImage')
@validate_json_request('MoveTag')
def put(self, namespace, repository, tag):
@ -116,6 +119,7 @@ class RepositoryTag(RepositoryParamResource):
return 'Updated', 201
@require_repo_write
@disallow_for_app_repositories
@nickname('deleteFullTag')
def delete(self, namespace, repository, tag):
""" Delete the specified repository tag. """
@ -136,6 +140,7 @@ class RepositoryTagImages(RepositoryParamResource):
""" Resource for listing the images in a specific repository tag. """
@require_repo_read
@nickname('listTagImages')
@disallow_for_app_repositories
@parse_args()
@query_param('owned', 'If specified, only images wholely owned by this tag are returned.',
type=truthy_bool, default=False)
@ -206,6 +211,7 @@ class RestoreTag(RepositoryParamResource):
}
@require_repo_write
@disallow_for_app_repositories
@nickname('restoreTag')
@validate_json_request('RestoreTag')
def post(self, namespace, repository, tag):

View file

View file

@ -0,0 +1,58 @@
import datetime
import json
from contextlib import contextmanager
from data import model
from endpoints.api import api
CSRF_TOKEN_KEY = '_csrf_token'
CSRF_TOKEN = '123csrfforme'
@contextmanager
def client_with_identity(auth_username, client):
with client.session_transaction() as sess:
if auth_username:
if auth_username is not None:
loaded = model.user.get_user(auth_username)
sess['user_id'] = loaded.uuid
sess['login_time'] = datetime.datetime.now()
sess[CSRF_TOKEN_KEY] = CSRF_TOKEN
yield client
with client.session_transaction() as sess:
sess['user_id'] = None
sess['login_time'] = None
sess[CSRF_TOKEN_KEY] = None
def add_csrf_param(params):
""" Returns a params dict with the CSRF parameter added. """
params = params or {}
params[CSRF_TOKEN_KEY] = CSRF_TOKEN
return params
def conduct_api_call(client, resource, method, params, body=None, expected_code=200):
""" Conducts an API call to the given resource via the given client, and ensures its returned
status matches the code given.
Returns the response.
"""
params = add_csrf_param(params)
final_url = api.url_for(resource, **params)
headers = {}
headers.update({"Content-Type": "application/json"})
if body is not None:
body = json.dumps(body)
rv = client.open(final_url, method=method, data=body, headers=headers)
msg = '%s %s: got %s expected: %s | %s' % (method, final_url, rv.status_code, expected_code,
rv.data)
assert rv.status_code == expected_code, msg
return rv

View file

@ -0,0 +1,80 @@
import pytest
from data import model
from endpoints.api.repository import Repository
from endpoints.api.build import (RepositoryBuildList, RepositoryBuildResource,
RepositoryBuildStatus, RepositoryBuildLogs)
from endpoints.api.image import RepositoryImageList, RepositoryImage
from endpoints.api.manifest import RepositoryManifestLabels, ManageRepositoryManifestLabel
from endpoints.api.repositorynotification import (RepositoryNotification,
RepositoryNotificationList,
TestRepositoryNotification)
from endpoints.api.secscan import RepositoryImageSecurity, RepositoryManifestSecurity
from endpoints.api.tag import ListRepositoryTags, RepositoryTag, RepositoryTagImages, RestoreTag
from endpoints.api.trigger import (BuildTriggerList, BuildTrigger, BuildTriggerSubdirs,
BuildTriggerActivate, BuildTriggerAnalyze, ActivateBuildTrigger,
TriggerBuildList, BuildTriggerFieldValues, BuildTriggerSources,
BuildTriggerSourceNamespaces)
from endpoints.api.test.shared import client_with_identity, conduct_api_call
from endpoints.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
BUILD_ARGS = {'build_uuid': '1234'}
IMAGE_ARGS = {'imageid': '1234', 'image_id': 1234}
MANIFEST_ARGS = {'manifestref': 'sha256:abcd1234'}
LABEL_ARGS = {'manifestref': 'sha256:abcd1234', 'labelid': '1234'}
NOTIFICATION_ARGS = {'uuid': '1234'}
TAG_ARGS = {'tag': 'foobar'}
TRIGGER_ARGS = {'trigger_uuid': '1234'}
FIELD_ARGS = {'trigger_uuid': '1234', 'field_name': 'foobar'}
@pytest.mark.parametrize('resource, method, params', [
(Repository, 'delete', None),
(RepositoryBuildList, 'get', None),
(RepositoryBuildList, 'post', None),
(RepositoryBuildResource, 'get', BUILD_ARGS),
(RepositoryBuildResource, 'delete', BUILD_ARGS),
(RepositoryBuildStatus, 'get', BUILD_ARGS),
(RepositoryBuildLogs, 'get', BUILD_ARGS),
(RepositoryImageList, 'get', None),
(RepositoryImage, 'get', IMAGE_ARGS),
(RepositoryManifestLabels, 'get', MANIFEST_ARGS),
(RepositoryManifestLabels, 'post', MANIFEST_ARGS),
(ManageRepositoryManifestLabel, 'get', LABEL_ARGS),
(ManageRepositoryManifestLabel, 'delete', LABEL_ARGS),
(RepositoryNotificationList, 'get', None),
(RepositoryNotificationList, 'post', None),
(RepositoryNotification, 'get', NOTIFICATION_ARGS),
(RepositoryNotification, 'delete', NOTIFICATION_ARGS),
(TestRepositoryNotification, 'post', NOTIFICATION_ARGS),
(RepositoryImageSecurity, 'get', IMAGE_ARGS),
(RepositoryManifestSecurity, 'get', MANIFEST_ARGS),
(ListRepositoryTags, 'get', None),
(RepositoryTag, 'put', TAG_ARGS),
(RepositoryTag, 'delete', TAG_ARGS),
(RepositoryTagImages, 'get', TAG_ARGS),
(RestoreTag, 'post', TAG_ARGS),
(BuildTriggerList, 'get', None),
(BuildTrigger, 'get', TRIGGER_ARGS),
(BuildTrigger, 'delete', TRIGGER_ARGS),
(BuildTriggerSubdirs, 'post', TRIGGER_ARGS),
(BuildTriggerActivate, 'post', TRIGGER_ARGS),
(BuildTriggerAnalyze, 'post', TRIGGER_ARGS),
(ActivateBuildTrigger, 'post', TRIGGER_ARGS),
(TriggerBuildList, 'get', TRIGGER_ARGS),
(BuildTriggerFieldValues, 'post', FIELD_ARGS),
(BuildTriggerSources, 'post', TRIGGER_ARGS),
(BuildTriggerSourceNamespaces, 'get', TRIGGER_ARGS),
])
def test_disallowed_for_apps(resource, method, params, client):
namespace = 'devtable'
repository = 'someapprepo'
devtable = model.user.get_user('devtable')
model.repository.create_repository(namespace, repository, devtable, repo_kind='application')
params = params or {}
params['repository'] = '%s/%s' % (namespace, repository)
with client_with_identity('devtable', client) as cl:
conduct_api_call(cl, resource, method, params, None, 501)

View file

@ -1,44 +1,29 @@
import datetime
import pytest
from data import model
from endpoints.api import api
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.test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
TEAM_PARAMS = {'orgname': 'buynlarge', 'teamname': 'owners'}
BUILD_PARAMS = {'build_uuid': 'test-1234'}
def client_with_identity(auth_username, client):
with client.session_transaction() as sess:
if auth_username:
if auth_username is not None:
loaded = model.user.get_user(auth_username)
sess['user_id'] = loaded.uuid
sess['login_time'] = datetime.datetime.now()
return client
@pytest.mark.parametrize('resource,method,params,body,identity,expected', [
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, None, 401),
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'reader', 403),
(SuperUserRepositoryBuildLogs, 'GET', BUILD_PARAMS, None, 'devtable', 400),
(SuperUserRepositoryBuildStatus, 'GET', BUILD_PARAMS, None, None, 401),
(SuperUserRepositoryBuildStatus, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
(SuperUserRepositoryBuildStatus, 'GET', BUILD_PARAMS, None, 'reader', 403),
(SuperUserRepositoryBuildStatus, 'GET', BUILD_PARAMS, None, 'devtable', 400),
@pytest.mark.parametrize('resource,identity,expected', [
(SuperUserRepositoryBuildLogs, None, 401),
(SuperUserRepositoryBuildLogs, 'freshuser', 403),
(SuperUserRepositoryBuildLogs, 'reader', 403),
(SuperUserRepositoryBuildLogs, 'devtable', 400),
(SuperUserRepositoryBuildStatus, None, 401),
(SuperUserRepositoryBuildStatus, 'freshuser', 403),
(SuperUserRepositoryBuildStatus, 'reader', 403),
(SuperUserRepositoryBuildStatus, 'devtable', 400),
(SuperUserRepositoryBuildResource, None, 401),
(SuperUserRepositoryBuildResource, 'freshuser', 403),
(SuperUserRepositoryBuildResource, 'reader', 403),
(SuperUserRepositoryBuildResource, 'devtable', 404),
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, None, 401),
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'reader', 403),
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'devtable', 404),
])
def test_super_user_build_endpoints(resource, identity, expected, client):
cl = client_with_identity(identity, client)
final_url = api.url_for(resource, build_uuid='1234')
rv = cl.open(final_url)
msg = '%s %s: %s expected: %s' % ('GET', final_url, rv.status_code, expected)
assert rv.status_code == expected, msg
def test_api_security(resource, method, params, body, identity, expected, client):
with client_with_identity(identity, client) as cl:
conduct_api_call(cl, resource, method, params, body, expected)

View file

@ -15,7 +15,8 @@ from buildtrigger.triggerutil import (TriggerDeactivationException,
RepositoryReadException, TriggerStartException)
from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin,
log_action, request_error, query_param, parse_args, internal_only,
validate_json_request, api, path_param, abort)
validate_json_request, api, path_param, abort,
disallow_for_app_repositories)
from endpoints.exception import NotFound, Unauthorized, InvalidRequest
from endpoints.api.build import build_status_view, trigger_view, RepositoryBuildStatus
from endpoints.building import start_build, MaximumBuildsQueuedException
@ -40,6 +41,7 @@ class BuildTriggerList(RepositoryParamResource):
""" Resource for listing repository build triggers. """
@require_repo_admin
@disallow_for_app_repositories
@nickname('listBuildTriggers')
def get(self, namespace_name, repo_name):
""" List the triggers for the specified repository. """
@ -56,6 +58,7 @@ class BuildTrigger(RepositoryParamResource):
""" Resource for managing specific build triggers. """
@require_repo_admin
@disallow_for_app_repositories
@nickname('getBuildTrigger')
def get(self, namespace_name, repo_name, trigger_uuid):
""" Get information for the specified build trigger. """
@ -67,6 +70,7 @@ class BuildTrigger(RepositoryParamResource):
return trigger_view(trigger, can_admin=True)
@require_repo_admin
@disallow_for_app_repositories
@nickname('deleteBuildTrigger')
def delete(self, namespace_name, repo_name, trigger_uuid):
""" Delete the specified build trigger. """
@ -110,6 +114,7 @@ class BuildTriggerSubdirs(RepositoryParamResource):
}
@require_repo_admin
@disallow_for_app_repositories
@nickname('listBuildTriggerSubdirs')
@validate_json_request('BuildTriggerSubdirRequest')
def post(self, namespace_name, repo_name, trigger_uuid):
@ -170,6 +175,7 @@ class BuildTriggerActivate(RepositoryParamResource):
}
@require_repo_admin
@disallow_for_app_repositories
@nickname('activateBuildTrigger')
@validate_json_request('BuildTriggerActivateRequest')
def post(self, namespace_name, repo_name, trigger_uuid):
@ -271,6 +277,7 @@ class BuildTriggerAnalyze(RepositoryParamResource):
}
@require_repo_admin
@disallow_for_app_repositories
@nickname('analyzeBuildTrigger')
@validate_json_request('BuildTriggerAnalyzeRequest')
def post(self, namespace_name, repo_name, trigger_uuid):
@ -420,6 +427,7 @@ class ActivateBuildTrigger(RepositoryParamResource):
}
@require_repo_admin
@disallow_for_app_repositories
@nickname('manuallyStartBuildTrigger')
@validate_json_request('RunParameters')
def post(self, namespace_name, repo_name, trigger_uuid):
@ -460,6 +468,7 @@ class ActivateBuildTrigger(RepositoryParamResource):
class TriggerBuildList(RepositoryParamResource):
""" Resource to represent builds that were activated from the specified trigger. """
@require_repo_admin
@disallow_for_app_repositories
@parse_args()
@query_param('limit', 'The maximum number of builds to return', type=int, default=5)
@nickname('listTriggerRecentBuilds')
@ -479,6 +488,7 @@ FIELD_VALUE_LIMIT = 30
class BuildTriggerFieldValues(RepositoryParamResource):
""" Custom verb to fetch a values list for a particular field name. """
@require_repo_admin
@disallow_for_app_repositories
@nickname('listTriggerFieldValues')
def post(self, namespace_name, repo_name, trigger_uuid, field_name):
""" List the field values for a custom run field. """
@ -522,6 +532,7 @@ class BuildTriggerSources(RepositoryParamResource):
}
@require_repo_admin
@disallow_for_app_repositories
@nickname('listTriggerBuildSources')
@validate_json_request('BuildTriggerSourcesRequest')
def post(self, namespace_name, repo_name, trigger_uuid):
@ -555,6 +566,7 @@ class BuildTriggerSources(RepositoryParamResource):
class BuildTriggerSourceNamespaces(RepositoryParamResource):
""" Custom verb to fetch the list of namespaces (orgs, projects, etc) for the trigger config. """
@require_repo_admin
@disallow_for_app_repositories
@nickname('listTriggerBuildSourceNamespaces')
def get(self, namespace_name, repo_name, trigger_uuid):
""" List the build sources for the trigger configuration thus far. """

View file

@ -29,6 +29,9 @@ class MaximumBuildsQueuedException(Exception):
def start_build(repository, prepared_build, pull_robot_name=None):
if repository.kind.name != 'image':
raise Exception('Attempt to start a build for application repository %s' % repository.id)
if MAX_BUILD_QUEUE_RATE_ITEMS > 0 and MAX_BUILD_QUEUE_RATE_SECS > 0:
queue_item_canonical_name = [repository.namespace_user.username, repository.name]
now = datetime.utcnow()

View file

@ -31,6 +31,8 @@ def attach_github_build_trigger(namespace_name, repo_name):
if not repo:
msg = 'Invalid repository: %s/%s' % (namespace_name, repo_name)
abort(404, message=msg)
elif repo.kind.name != 'image':
abort(501)
trigger = model.build.create_build_trigger(repo, 'github', token, current_user.db_user())
repo_path = '%s/%s' % (namespace_name, repo_name)

View file

@ -44,6 +44,8 @@ def attach_gitlab_build_trigger():
if not repo:
msg = 'Invalid repository: %s/%s' % (namespace, repository)
abort(404, message=msg)
elif repo.kind.name != 'image':
abort(501)
trigger = model.build.create_build_trigger(repo, 'gitlab', token, current_user.db_user())
repo_path = '%s/%s' % (namespace, repository)

View file

@ -182,6 +182,10 @@ def create_repository(namespace_name, repo_name):
message='You do not have permission to modify repository %(namespace)s/%(repository)s',
issue='no-repo-write-permission',
namespace=namespace_name, repository=repo_name)
elif repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, namespace=namespace_name)
else:
create_perm = CreateRepositoryPermission(namespace_name)
if not create_perm.can():
@ -223,6 +227,9 @@ def update_images(namespace_name, repo_name):
if not repo:
# Make sure the repo actually exists.
abort(404, message='Unknown repository', issue='unknown-repo')
elif repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, namespace=namespace_name)
# Generate a job for each notification that has been added to this repo
logger.debug('Adding notifications for repository')
@ -255,6 +262,9 @@ def get_repository_images(namespace_name, repo_name):
repo = model.get_repository(namespace_name, repo_name)
if not repo:
abort(404, message='Unknown repository', issue='unknown-repo')
elif repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, namespace=namespace_name)
logger.debug('Building repository image response')
resp = make_response(json.dumps([]), 200)

View file

@ -83,6 +83,11 @@ def head_image_layer(namespace, repository, image_id, headers):
logger.debug('Checking repo permissions')
if permission.can() or model.repository_is_public(namespace, repository):
repo = model.get_repository(namespace, repository)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, image_id=image_id)
logger.debug('Looking up placement locations')
locations, _ = model.placement_locations_and_path_docker_v1(namespace, repository, image_id)
if locations is None:
@ -116,6 +121,11 @@ def get_image_layer(namespace, repository, image_id, headers):
logger.debug('Checking repo permissions')
if permission.can() or model.repository_is_public(namespace, repository):
repo = model.get_repository(namespace, repository)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, image_id=image_id)
logger.debug('Looking up placement locations and path')
locations, path = model.placement_locations_and_path_docker_v1(namespace, repository, image_id)
if not locations or not path:
@ -151,6 +161,11 @@ def put_image_layer(namespace, repository, image_id):
if not permission.can():
abort(403)
repo = model.get_repository(namespace, repository)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, image_id=image_id)
logger.debug('Retrieving image')
if model.storage_exists(namespace, repository, image_id):
exact_abort(409, 'Image already exists')
@ -255,6 +270,11 @@ def put_image_checksum(namespace, repository, image_id):
if not permission.can():
abort(403)
repo = model.get_repository(namespace, repository)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, image_id=image_id)
# Docker Version < 0.10 (tarsum+sha):
old_checksum = request.headers.get('X-Docker-Checksum')
@ -324,6 +344,11 @@ def get_image_json(namespace, repository, image_id, headers):
if not permission.can() and not model.repository_is_public(namespace, repository):
abort(403)
repo = model.get_repository(namespace, repository)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, image_id=image_id)
logger.debug('Looking up repo image')
v1_metadata = model.docker_v1_metadata(namespace, repository, image_id)
if v1_metadata is None:
@ -353,6 +378,11 @@ def get_image_ancestry(namespace, repository, image_id, headers):
if not permission.can() and not model.repository_is_public(namespace, repository):
abort(403)
repo = model.get_repository(namespace, repository)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, image_id=image_id)
ancestry_docker_ids = model.image_ancestry(namespace, repository, image_id)
if ancestry_docker_ids is None:
abort(404, 'Image %(image_id)s not found', issue='unknown-image', image_id=image_id)
@ -373,6 +403,11 @@ def put_image_json(namespace, repository, image_id):
if not permission.can():
abort(403)
repo = model.get_repository(namespace, repository)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, image_id=image_id)
logger.debug('Parsing image JSON')
try:
uploaded_metadata = request.data

View file

@ -27,6 +27,11 @@ def get_tags(namespace_name, repo_name):
permission = ReadRepositoryPermission(namespace_name, repo_name)
if permission.can() or model.repository_is_public(namespace_name, repo_name):
repo = model.get_repository(namespace_name, repo_name)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, namespace=namespace_name)
tags = model.list_tags(namespace_name, repo_name)
tag_map = {tag.name: tag.image.docker_image_id for tag in tags}
return jsonify(tag_map)
@ -42,6 +47,11 @@ def get_tag(namespace_name, repo_name, tag):
permission = ReadRepositoryPermission(namespace_name, repo_name)
if permission.can() or model.repository_is_public(namespace_name, repo_name):
repo = model.get_repository(namespace_name, repo_name)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, namespace=namespace_name)
image_id = model.find_image_id_by_tag(namespace_name, repo_name, tag)
if image_id is None:
abort(404)
@ -64,6 +74,11 @@ def put_tag(namespace_name, repo_name, tag):
if not TAG_REGEX.match(tag):
abort(400, TAG_ERROR)
repo = model.get_repository(namespace_name, repo_name)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, namespace=namespace_name)
image_id = json.loads(request.data)
model.create_or_update_tag(namespace_name, repo_name, image_id, tag)
@ -86,6 +101,11 @@ def delete_tag(namespace_name, repo_name, tag):
permission = ModifyRepositoryPermission(namespace_name, repo_name)
if permission.can():
repo = model.get_repository(namespace_name, repo_name)
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
abort(405, message=msg, namespace=namespace_name)
model.delete_tag(namespace_name, repo_name, tag)
track_and_log('delete_tag', model.get_repository(namespace_name, repo_name), tag=tag)
return make_response('Deleted', 200)

View file

@ -15,9 +15,9 @@ from auth.auth_context import get_grant_context
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
AdministerRepositoryPermission)
from auth.registry_jwt_auth import process_registry_jwt_auth, get_auth_headers
from data import model
from data.interfaces.v2 import pre_oci_model as model
from endpoints.decorators import anon_protect, anon_allowed
from endpoints.v2.errors import V2RegistryException, Unauthorized
from endpoints.v2.errors import V2RegistryException, Unauthorized, Unsupported, NameUnknown
from util.http import abort
from util.metrics.metricqueue import time_blueprint
from util.registry.dockerver import docker_version
@ -96,13 +96,21 @@ def _require_repo_permission(permission_class, scopes=None, allow_public=False):
def wrapped(namespace_name, repo_name, *args, **kwargs):
logger.debug('Checking permission %s for repo: %s/%s', permission_class,
namespace_name, repo_name)
repository = namespace_name + '/' + repo_name
repo = model.get_repository(namespace_name, repo_name)
if repo is None:
raise Unauthorized(repository=repository, scopes=scopes)
permission = permission_class(namespace_name, repo_name)
if (permission.can() or
(allow_public and
model.repository.repository_is_public(namespace_name, repo_name))):
repo.is_public)):
if repo.kind != 'image':
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
raise Unsupported(detail=msg)
return func(namespace_name, repo_name, *args, **kwargs)
repository = namespace_name + '/' + repo_name
raise Unauthorized(repository=repository, scopes=scopes)
return wrapped
return wrapper

View file

@ -3,7 +3,6 @@ from flask import jsonify
from auth.registry_jwt_auth import process_registry_jwt_auth
from endpoints.common import parse_repository_name
from endpoints.v2 import v2_bp, require_repo_read, paginate
from endpoints.v2.errors import NameUnknown
from endpoints.decorators import anon_protect
from data.interfaces.v2 import pre_oci_model as model
@ -14,10 +13,6 @@ from data.interfaces.v2 import pre_oci_model as model
@anon_protect
@paginate()
def list_all_tags(namespace_name, repo_name, limit, offset, pagination_callback):
repo = model.get_repository(namespace_name, repo_name)
if repo is None:
raise NameUnknown()
tags = model.repository_tags(namespace_name, repo_name, limit, offset)
response = jsonify({
'name': '{0}/{1}'.format(namespace_name, repo_name),

View file

@ -91,15 +91,25 @@ def generate_registry_jwt():
final_actions = []
repo = model.get_repository(namespace, reponame)
repo_is_public = repo is not None and repo.is_public
invalid_repo_message = ''
if repo is not None and repo.kind != 'image':
invalid_repo_message = (('This repository is for managing %s resources ' +
'and not container images.') % repo.kind)
if 'push' in actions:
# If there is no valid user or token, then the repository cannot be
# accessed.
if user is not None or token is not None:
# Lookup the repository. If it exists, make sure the entity has modify
# permission. Otherwise, make sure the entity has create permission.
repo = model.get_repository(namespace, reponame)
if repo:
if ModifyRepositoryPermission(namespace, reponame).can():
if repo.kind != 'image':
abort(405, invalid_repo_message)
final_actions.append('push')
else:
logger.debug('No permission to modify repository %s/%s', namespace, reponame)
@ -113,19 +123,24 @@ def generate_registry_jwt():
if 'pull' in actions:
# Grant pull if the user can read the repo or it is public.
if (ReadRepositoryPermission(namespace, reponame).can() or
model.repository_is_public(namespace, reponame)):
if ReadRepositoryPermission(namespace, reponame).can() or repo_is_public:
if repo is not None and repo.kind != 'image':
abort(405, invalid_repo_message)
final_actions.append('pull')
else:
logger.debug('No permission to pull repository %s/%s', namespace, reponame)
if '*' in actions:
# Grant * user is admin
if (AdministerRepositoryPermission(namespace, reponame).can()):
if AdministerRepositoryPermission(namespace, reponame).can():
if repo is not None and repo.kind != 'image':
abort(405, invalid_repo_message)
final_actions.append('*')
else:
logger.debug("No permission to administer repository %s/%s", namespace, reponame)
# Add the access for the JWT.
access.append({
'type': 'repository',

View file

@ -154,29 +154,35 @@ def _torrent_repo_verb(repo_image, tag, verb, **kwargs):
abort(406)
# Return the torrent.
public_repo = model.repository_is_public(repo_image.repository.namespace_name,
repo_image.repository.name)
torrent = _torrent_for_blob(derived_image.blob, public_repo)
repo = model.get_repository(repo_image.repository.namespace_name,
repo_image.repository.name)
repo_is_public = repo is not None and repo.is_public
torrent = _torrent_for_blob(derived_image.blob, repo_is_public)
# Log the action.
track_and_log('repo_verb', repo_image.repository, tag=tag, verb=verb, torrent=True, **kwargs)
return torrent
def _verify_repo_verb(_, namespace, repository, tag, verb, checker=None):
permission = ReadRepositoryPermission(namespace, repository)
if not permission.can() and not model.repository_is_public(namespace, repository):
def _verify_repo_verb(_, namespace, repo_name, tag, verb, checker=None):
permission = ReadRepositoryPermission(namespace, repo_name)
repo = model.get_repository(namespace, repo_name)
repo_is_public = repo is not None and repo.is_public
if not permission.can() and not repo_is_public:
abort(403)
# Lookup the requested tag.
tag_image = model.get_tag_image(namespace, repository, tag)
tag_image = model.get_tag_image(namespace, repo_name, tag)
if tag_image is None:
abort(404)
if repo is not None and repo.kind != 'image':
abort(405)
# If there is a data checker, call it first.
if checker is not None:
if not checker(tag_image):
logger.debug('Check mismatch on %s/%s:%s, verb %s', namespace, repository, tag, verb)
logger.debug('Check mismatch on %s/%s:%s, verb %s', namespace, repo_name, tag, verb)
abort(404)
return tag_image
@ -345,19 +351,24 @@ def get_squashed_tag(namespace, repository, tag):
@process_auth
@parse_repository_name()
def get_tag_torrent(namespace_name, repo_name, digest):
repo = model.get_repository(namespace_name, repo_name)
repo_is_public = repo is not None and repo.is_public
permission = ReadRepositoryPermission(namespace_name, repo_name)
public_repo = model.repository_is_public(namespace_name, repo_name)
if not permission.can() and not public_repo:
if not permission.can() and not repo_is_public:
abort(403)
user = get_authenticated_user()
if user is None and not public_repo:
if user is None and not repo_is_public:
# We can not generate a private torrent cluster without a user uuid (e.g. token auth)
abort(403)
if repo is not None and repo.kind != 'image':
abort(405)
blob = model.get_repo_blob_by_digest(namespace_name, repo_name, digest)
if blob is None:
abort(404)
metric_queue.repository_pull.Inc(labelvalues=[namespace_name, repo_name, 'torrent', True])
return _torrent_for_blob(blob, public_repo)
return _torrent_for_blob(blob, repo_is_public)

View file

@ -426,9 +426,12 @@ def confirm_recovery():
@anon_protect
def build_status_badge(namespace_name, repo_name):
token = request.args.get('token', None)
repo = model.repository.get_repository(namespace_name, repo_name)
if repo and repo.kind.name != 'image':
abort(404)
is_public = model.repository.repository_is_public(namespace_name, repo_name)
if not is_public:
repo = model.repository.get_repository(namespace_name, repo_name)
if not repo or token != repo.badge_token:
abort(404)
@ -628,6 +631,8 @@ def attach_bitbucket_trigger(namespace_name, repo_name):
if not repo:
msg = 'Invalid repository: %s/%s' % (namespace_name, repo_name)
abort(404, message=msg)
elif repo.kind.name != 'image':
abort(501)
trigger = model.build.create_build_trigger(repo, BitbucketBuildTrigger.service_name(), None,
current_user.db_user())
@ -661,6 +666,8 @@ def attach_custom_build_trigger(namespace_name, repo_name):
if not repo:
msg = 'Invalid repository: %s/%s' % (namespace_name, repo_name)
abort(404, message=msg)
elif repo.kind.name != 'image':
abort(501)
trigger = model.build.create_build_trigger(repo, CustomBuildTrigger.service_name(),
None, current_user.db_user())

View file

@ -19,7 +19,8 @@ from data.database import (db, all_models, beta_classes, Role, TeamRole, Visibil
ImageStorageTransformation, ImageStorageSignatureKind,
ExternalNotificationEvent, ExternalNotificationMethod, NotificationKind,
QuayRegion, QuayService, UserRegion, OAuthAuthorizationCode,
ServiceKeyApprovalType, MediaType, LabelSourceType, UserPromptKind)
ServiceKeyApprovalType, MediaType, LabelSourceType, UserPromptKind,
RepositoryKind, TagKind, BlobPlacementLocation)
from data import model
from data.queue import WorkQueue
from app import app, storage as store, tf
@ -350,6 +351,9 @@ def initialize_database():
ImageStorageLocation.create(name='local_eu')
ImageStorageLocation.create(name='local_us')
BlobPlacementLocation.create(name='local_eu')
BlobPlacementLocation.create(name='local_us')
ImageStorageTransformation.create(name='squash')
ImageStorageTransformation.create(name='aci')
@ -396,6 +400,15 @@ def initialize_database():
MediaType.create(name='text/plain')
MediaType.create(name='application/json')
MediaType.create(name='text/markdown')
MediaType.create(name='application/vnd.cnr.blob.v0.tar+gzip')
MediaType.create(name='application/vnd.cnr.package-manifest.helm.v0.json')
MediaType.create(name='application/vnd.cnr.package-manifest.kpm.v0.json')
MediaType.create(name='application/vnd.cnr.package-manifest.docker-compose.v0.json')
MediaType.create(name='application/vnd.cnr.package.kpm.v0.tar+gzip')
MediaType.create(name='application/vnd.cnr.package.helm.v0.tar+gzip')
MediaType.create(name='application/vnd.cnr.package.docker-compose.v0.tar+gzip')
MediaType.create(name='application/vnd.cnr.manifests.v0.json')
MediaType.create(name='application/vnd.cnr.manifest.list.v0.json')
LabelSourceType.create(name='manifest')
LabelSourceType.create(name='api', mutable=True)
@ -405,6 +418,13 @@ def initialize_database():
UserPromptKind.create(name='enter_name')
UserPromptKind.create(name='enter_company')
RepositoryKind.create(name='image')
RepositoryKind.create(name='application')
TagKind.create(name='tag')
TagKind.create(name='release')
TagKind.create(name='channel')
def wipe_database():
logger.debug('Wiping all data from the DB.')

Binary file not shown.

View file

@ -168,6 +168,7 @@ class FailureCodes:
INVALID_REGISTRY = ('invalidregistry', 404, 404)
DOES_NOT_EXIST = ('doesnotexist', 404, 404)
INVALID_REQUEST = ('invalidrequest', 400, 400)
APP_REPOSITORY = ('apprepository', 405, 405)
def _get_expected_code(expected_failure, version, success_status_code):
""" Returns the HTTP status code for the expected failure under the specified protocol version
@ -545,6 +546,8 @@ class V2RegistryPushMixin(V2RegistryMixin):
expected_auth_code = 200
if expect_failure == FailureCodes.INVALID_REGISTRY:
expected_auth_code = 400
elif expect_failure == FailureCodes.APP_REPOSITORY:
expected_auth_code = 405
self.do_auth(username, password, namespace, repository, scopes=scopes or ['push', 'pull'],
expected_code=expected_auth_code)
@ -685,6 +688,8 @@ class V2RegistryPullMixin(V2RegistryMixin):
expected_auth_code = 200
if expect_failure == FailureCodes.UNAUTHENTICATED:
expected_auth_code = 401
elif expect_failure == FailureCodes.APP_REPOSITORY:
expected_auth_code = 405
self.do_auth(username, password, namespace, repository, scopes=['pull'],
expected_code=expected_auth_code)
@ -765,6 +770,26 @@ class V2RegistryLoginMixin(object):
class RegistryTestsMixin(object):
def test_application_repo(self):
# Create an application repository via the API.
self.conduct_api_login('devtable', 'password')
data = {
'repository': 'someapprepo',
'visibility': 'private',
'kind': 'application',
'description': 'test app repo',
}
self.conduct('POST', '/api/v1/repository', json_data=data, expected_code=201)
# Try to push to the repo, which should fail with a 405.
self.do_push('devtable', 'someapprepo', 'devtable', 'password',
expect_failure=FailureCodes.APP_REPOSITORY)
# Try to pull from the repo, which should fail with a 405.
self.do_pull('devtable', 'someapprepo', 'devtable', 'password',
expect_failure=FailureCodes.APP_REPOSITORY)
def test_middle_layer_different_sha(self):
if self.push_version == 'v1':
# No SHAs to munge in V1.

View file

@ -508,3 +508,101 @@ def build_v2_index_specs():
IndexV2TestSpec('v2.cancel_upload', 'DELETE', ANOTHER_ORG_REPO, upload_uuid=FAKE_UPLOAD_ID).
request_status(401, 401, 401, 401, 404),
]
class VerbTestSpec(object):
def __init__(self, index_name, method_name, repo_name, rpath=False, **kwargs):
self.index_name = index_name
self.repo_name = repo_name
self.method_name = method_name
self.single_repository_path = rpath
self.kwargs = kwargs
self.anon_code = 401
self.no_access_code = 403
self.read_code = 200
self.admin_code = 200
self.creator_code = 200
def request_status(self, anon_code=401, no_access_code=403, read_code=200, creator_code=200,
admin_code=200):
self.anon_code = anon_code
self.no_access_code = no_access_code
self.read_code = read_code
self.creator_code = creator_code
self.admin_code = admin_code
return self
def get_url(self):
if self.single_repository_path:
return url_for(self.index_name, repository=self.repo_name, **self.kwargs)
else:
(namespace, repo_name) = self.repo_name.split('/')
return url_for(self.index_name, namespace=namespace, repository=repo_name, **self.kwargs)
def gen_basic_auth(self, username, password):
encoded = b64encode('%s:%s' % (username, password))
return 'basic %s' % encoded
ACI_ARGS = {
'server': 'someserver',
'tag': 'fake',
'os': 'linux',
'arch': 'x64',
}
def build_verbs_specs():
return [
# get_aci_signature
VerbTestSpec('verbs.get_aci_signature', 'GET', PUBLIC_REPO, **ACI_ARGS).
request_status(404, 404, 404, 404, 404),
VerbTestSpec('verbs.get_aci_signature', 'GET', PRIVATE_REPO, **ACI_ARGS).
request_status(403, 403, 404, 403, 404),
VerbTestSpec('verbs.get_aci_signature', 'GET', ORG_REPO, **ACI_ARGS).
request_status(403, 403, 404, 403, 404),
VerbTestSpec('verbs.get_aci_signature', 'GET', ANOTHER_ORG_REPO, **ACI_ARGS).
request_status(403, 403, 403, 403, 404),
# get_aci_image
VerbTestSpec('verbs.get_aci_image', 'GET', PUBLIC_REPO, **ACI_ARGS).
request_status(404, 404, 404, 404, 404),
VerbTestSpec('verbs.get_aci_image', 'GET', PRIVATE_REPO, **ACI_ARGS).
request_status(403, 403, 404, 403, 404),
VerbTestSpec('verbs.get_aci_image', 'GET', ORG_REPO, **ACI_ARGS).
request_status(403, 403, 404, 403, 404),
VerbTestSpec('verbs.get_aci_image', 'GET', ANOTHER_ORG_REPO, **ACI_ARGS).
request_status(403, 403, 403, 403, 404),
# get_squashed_tag
VerbTestSpec('verbs.get_squashed_tag', 'GET', PUBLIC_REPO, tag='fake').
request_status(404, 404, 404, 404, 404),
VerbTestSpec('verbs.get_squashed_tag', 'GET', PRIVATE_REPO, tag='fake').
request_status(403, 403, 404, 403, 404),
VerbTestSpec('verbs.get_squashed_tag', 'GET', ORG_REPO, tag='fake').
request_status(403, 403, 404, 403, 404),
VerbTestSpec('verbs.get_squashed_tag', 'GET', ANOTHER_ORG_REPO, tag='fake').
request_status(403, 403, 403, 403, 404),
# get_tag_torrent
VerbTestSpec('verbs.get_tag_torrent', 'GET', PUBLIC_REPO, digest='sha256:1234', rpath=True).
request_status(404, 404, 404, 404, 404),
VerbTestSpec('verbs.get_tag_torrent', 'GET', PRIVATE_REPO, digest='sha256:1234', rpath=True).
request_status(403, 403, 404, 403, 404),
VerbTestSpec('verbs.get_tag_torrent', 'GET', ORG_REPO, digest='sha256:1234', rpath=True).
request_status(403, 403, 404, 403, 404),
VerbTestSpec('verbs.get_tag_torrent', 'GET', ANOTHER_ORG_REPO, digest='sha256:1234', rpath=True).
request_status(403, 403, 403, 403, 404),
]

View file

@ -2551,8 +2551,8 @@ class TestRepoBuilds(ApiTestCase):
def test_getrepo_nobuilds(self):
self.login(ADMIN_ACCESS_USER)
# Queries: Permission + the list query
with assert_query_count(2):
# Queries: Permission + the list query + app check
with assert_query_count(3):
json = self.getJsonResponse(RepositoryBuildList,
params=dict(repository=ADMIN_ACCESS_USER + '/simple'))
@ -2561,8 +2561,8 @@ class TestRepoBuilds(ApiTestCase):
def test_getrepobuilds(self):
self.login(ADMIN_ACCESS_USER)
# Queries: Permission + the list query
with assert_query_count(2):
# Queries: Permission + the list query + app check
with assert_query_count(3):
json = self.getJsonResponse(RepositoryBuildList,
params=dict(repository=ADMIN_ACCESS_USER + '/building'))

View file

@ -0,0 +1,100 @@
import unittest
import endpoints.decorated # Register the various exceptions via decorators.
from app import app
from endpoints.verbs import verbs
from initdb import setup_database_for_testing, finished_database_for_testing
from test.specs import build_verbs_specs
app.register_blueprint(verbs, url_prefix='/c1')
NO_ACCESS_USER = 'freshuser'
READ_ACCESS_USER = 'reader'
ADMIN_ACCESS_USER = 'devtable'
CREATOR_ACCESS_USER = 'creator'
class EndpointTestCase(unittest.TestCase):
def setUp(self):
setup_database_for_testing(self)
def tearDown(self):
finished_database_for_testing(self)
class _SpecTestBuilder(type):
@staticmethod
def _test_generator(url, test_spec, attrs):
def test(self):
with app.test_client() as c:
headers = {}
if attrs['auth_username']:
headers['Authorization'] = test_spec.gen_basic_auth(attrs['auth_username'], 'password')
expected_status = getattr(test_spec, attrs['result_attr'])
rv = c.open(url, headers=headers, method=test_spec.method_name)
msg = '%s %s: got %s, expected: %s (auth: %s | headers %s)' % (test_spec.method_name,
test_spec.index_name, rv.status_code, expected_status, attrs['auth_username'],
headers)
self.assertEqual(rv.status_code, expected_status, msg)
return test
def __new__(cls, name, bases, attrs):
with app.test_request_context() as ctx:
specs = attrs['spec_func']()
for test_spec in specs:
test_name = '%s_%s_%s_%s_%s' % (test_spec.index_name, test_spec.method_name,
test_spec.repo_name, attrs['auth_username'] or 'anon',
attrs['result_attr'])
test_name = test_name.replace('/', '_').replace('-', '_')
test_name = 'test_' + test_name.lower().replace('verbs.', 'verbs_')
url = test_spec.get_url()
attrs[test_name] = _SpecTestBuilder._test_generator(url, test_spec, attrs)
return type(name, bases, attrs)
class TestAnonymousAccess(EndpointTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_verbs_specs
result_attr = 'anon_code'
auth_username = None
class TestNoAccess(EndpointTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_verbs_specs
result_attr = 'no_access_code'
auth_username = NO_ACCESS_USER
class TestReadAccess(EndpointTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_verbs_specs
result_attr = 'read_code'
auth_username = READ_ACCESS_USER
class TestCreatorAccess(EndpointTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_verbs_specs
result_attr = 'creator_code'
auth_username = CREATOR_ACCESS_USER
class TestAdminAccess(EndpointTestCase):
__metaclass__ = _SpecTestBuilder
spec_func = build_verbs_specs
result_attr = 'admin_code'
auth_username = ADMIN_ACCESS_USER
if __name__ == '__main__':
unittest.main()