commit
3ccf3c5f33
40 changed files with 790 additions and 136 deletions
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'},
|
||||
]
|
||||
)
|
||||
|
||||
|
|
44
data/migrations/versions/b4df55dea4b3_add_repository_kind.py
Normal file
44
data/migrations/versions/b4df55dea4b3_add_repository_kind.py
Normal 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')
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
14
data/model/test/test_repository.py
Normal file
14
data/model/test/test_repository.py
Normal 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')
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
0
endpoints/api/test/__init__.py
Normal file
0
endpoints/api/test/__init__.py
Normal file
58
endpoints/api/test/shared.py
Normal file
58
endpoints/api/test/shared.py
Normal 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
|
80
endpoints/api/test/test_disallow_for_apps.py
Normal file
80
endpoints/api/test/test_disallow_for_apps.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
@ -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. """
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
|
22
initdb.py
22
initdb.py
|
@ -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.
|
@ -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.
|
||||
|
|
|
@ -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),
|
||||
]
|
||||
|
|
|
@ -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'))
|
||||
|
||||
|
|
100
test/test_verbs_endpoint_security.py
Normal file
100
test/test_verbs_endpoint_security.py
Normal 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()
|
Reference in a new issue