diff --git a/endpoints/v1/index.py b/endpoints/v1/index.py index 1f34055da..74d6b9c2d 100644 --- a/endpoints/v1/index.py +++ b/endpoints/v1/index.py @@ -30,6 +30,16 @@ class GrantType(object): WRITE_REPOSITORY = 'write' +def ensure_namespace_enabled(f): + @wraps(f) + def wrapper(namespace_name, repo_name, *args, **kwargs): + if not model.is_namespace_enabled(namespace_name): + abort(400, message='Namespace is disabled. Please contact your system administrator.') + + return f(namespace_name, repo_name, *args, **kwargs) + return wrapper + + def generate_headers(scope=GrantType.READ_REPOSITORY, add_grant_for_status=None): def decorator_method(f): @wraps(f) @@ -149,6 +159,7 @@ def update_user(username): @v1_bp.route('/repositories//', methods=['PUT']) @process_auth @parse_repository_name() +@ensure_namespace_enabled @generate_headers(scope=GrantType.WRITE_REPOSITORY, add_grant_for_status=201) @anon_allowed def create_repository(namespace_name, repo_name): @@ -205,6 +216,7 @@ def create_repository(namespace_name, repo_name): @v1_bp.route('/repositories//images', methods=['PUT']) @process_auth @parse_repository_name() +@ensure_namespace_enabled @generate_headers(scope=GrantType.WRITE_REPOSITORY) @anon_allowed def update_images(namespace_name, repo_name): @@ -238,6 +250,7 @@ def update_images(namespace_name, repo_name): @v1_bp.route('/repositories//images', methods=['GET']) @process_auth @parse_repository_name() +@ensure_namespace_enabled @generate_headers(scope=GrantType.READ_REPOSITORY) @anon_protect def get_repository_images(namespace_name, repo_name): @@ -268,6 +281,7 @@ def get_repository_images(namespace_name, repo_name): @v1_bp.route('/repositories//images', methods=['DELETE']) @process_auth @parse_repository_name() +@ensure_namespace_enabled @generate_headers(scope=GrantType.WRITE_REPOSITORY) @anon_allowed def delete_repository_images(namespace_name, repo_name): @@ -276,6 +290,7 @@ def delete_repository_images(namespace_name, repo_name): @v1_bp.route('/repositories//auth', methods=['PUT']) @parse_repository_name() +@ensure_namespace_enabled @anon_allowed def put_repository_auth(namespace_name, repo_name): abort(501, 'Not Implemented', issue='not-implemented') diff --git a/endpoints/v1/models_interface.py b/endpoints/v1/models_interface.py index 0d03d7005..a4d4455c9 100644 --- a/endpoints/v1/models_interface.py +++ b/endpoints/v1/models_interface.py @@ -203,3 +203,8 @@ class DockerRegistryV1DataInterface(object): Returns a sorted list of repositories matching the given search term. """ pass + + @abstractmethod + def is_namespace_enabled(self, namespace_name): + """ Returns whether the given namespace exists and is enabled. """ + pass diff --git a/endpoints/v1/models_pre_oci.py b/endpoints/v1/models_pre_oci.py index 030c90545..0b11eccdb 100644 --- a/endpoints/v1/models_pre_oci.py +++ b/endpoints/v1/models_pre_oci.py @@ -163,6 +163,10 @@ class PreOCIModel(DockerRegistryV1DataInterface): search_term, filter_username=filter_username, offset=offset, limit=limit) return [_repository_for_repo(repo) for repo in repos] + def is_namespace_enabled(self, namespace_name): + namespace = model.user.get_namespace_user(namespace_name) + return namespace is not None and namespace.enabled + def _repository_for_repo(repo): """ Returns a Repository object representing the Pre-OCI data model instance of a repository. """ diff --git a/endpoints/v1/registry.py b/endpoints/v1/registry.py index 0bb187fef..c83cfe2fb 100644 --- a/endpoints/v1/registry.py +++ b/endpoints/v1/registry.py @@ -15,6 +15,7 @@ from data import model, database from digest import checksums from endpoints.v1 import v1_bp from endpoints.v1.models_pre_oci import pre_oci_model as model +from endpoints.v1.index import ensure_namespace_enabled from endpoints.decorators import anon_protect from util.http import abort, exact_abort from util.registry.filelike import SocketReader @@ -75,6 +76,7 @@ def set_cache_headers(f): @v1_bp.route('/images//layer', methods=['HEAD']) @process_auth @extract_namespace_repo_from_session +@ensure_namespace_enabled @require_completion @set_cache_headers @anon_protect @@ -112,6 +114,7 @@ def head_image_layer(namespace, repository, image_id, headers): @v1_bp.route('/images//layer', methods=['GET']) @process_auth @extract_namespace_repo_from_session +@ensure_namespace_enabled @require_completion @set_cache_headers @anon_protect @@ -151,6 +154,7 @@ def get_image_layer(namespace, repository, image_id, headers): @v1_bp.route('/images//layer', methods=['PUT']) @process_auth @extract_namespace_repo_from_session +@ensure_namespace_enabled @anon_protect def put_image_layer(namespace, repository, image_id): logger.debug('Checking repo permissions') @@ -259,6 +263,7 @@ def put_image_layer(namespace, repository, image_id): @v1_bp.route('/images//checksum', methods=['PUT']) @process_auth @extract_namespace_repo_from_session +@ensure_namespace_enabled @anon_protect def put_image_checksum(namespace, repository, image_id): logger.debug('Checking repo permissions') @@ -331,6 +336,7 @@ def put_image_checksum(namespace, repository, image_id): @v1_bp.route('/images//json', methods=['GET']) @process_auth @extract_namespace_repo_from_session +@ensure_namespace_enabled @require_completion @set_cache_headers @anon_protect @@ -365,6 +371,7 @@ def get_image_json(namespace, repository, image_id, headers): @v1_bp.route('/images//ancestry', methods=['GET']) @process_auth @extract_namespace_repo_from_session +@ensure_namespace_enabled @require_completion @set_cache_headers @anon_protect @@ -392,6 +399,7 @@ def get_image_ancestry(namespace, repository, image_id, headers): @v1_bp.route('/images//json', methods=['PUT']) @process_auth @extract_namespace_repo_from_session +@ensure_namespace_enabled @anon_protect def put_image_json(namespace, repository, image_id): logger.debug('Checking repo permissions') diff --git a/endpoints/v2/errors.py b/endpoints/v2/errors.py index 40d7b9529..aad1bae4b 100644 --- a/endpoints/v2/errors.py +++ b/endpoints/v2/errors.py @@ -138,3 +138,9 @@ class InvalidLogin(V2RegistryException): class InvalidRequest(V2RegistryException): def __init__(self, message=None): super(InvalidRequest, self).__init__('INVALID_REQUEST', message or 'Invalid request', {}, 400) + + +class NamespaceDisabled(V2RegistryException): + def __init__(self, message=None): + message = message or 'This namespace is disabled. Please contact your system administrator.' + super(NamespaceDisabled, self).__init__('NAMESPACE_DISABLED', message, {}, 400) diff --git a/endpoints/v2/models_interface.py b/endpoints/v2/models_interface.py index 691de1936..e43687359 100644 --- a/endpoints/v2/models_interface.py +++ b/endpoints/v2/models_interface.py @@ -270,3 +270,9 @@ class DockerRegistryV2DataInterface(object): Looks up all blobs with the matching digests under the given repository. """ pass + + @abstractmethod + def is_namespace_enabled(self, namespace_name): + """ Returns whether the given namespace is enabled. If the namespace doesn't exist, + returns True. """ + pass diff --git a/endpoints/v2/models_pre_oci.py b/endpoints/v2/models_pre_oci.py index 2c7870c4c..86f64f454 100644 --- a/endpoints/v2/models_pre_oci.py +++ b/endpoints/v2/models_pre_oci.py @@ -258,6 +258,10 @@ class PreOCIModel(DockerRegistryV2DataInterface): except model.InvalidManifestException: return + def is_namespace_enabled(self, namespace_name): + namespace = model.user.get_namespace_user(namespace_name) + return namespace is None or namespace.enabled + def _docker_v1_metadata(namespace_name, repo_name, repo_image): """ diff --git a/endpoints/v2/test/test_v2auth.py b/endpoints/v2/test/test_v2auth.py index d658e849b..7cd8e7ad3 100644 --- a/endpoints/v2/test/test_v2auth.py +++ b/endpoints/v2/test/test_v2auth.py @@ -54,6 +54,11 @@ from test.fixtures import * ('repository:somenamespace/unknownrepo:pull,push', 'devtable', 'password', 200, ['somenamespace/unknownrepo:']), + # Disabled namespace. + (['repository:devtable/simple:pull,push', 'repository:disabled/complex:pull'], + 'devtable', 'password', 400, + []), + # Multiple scopes. (['repository:devtable/simple:pull,push', 'repository:devtable/complex:pull'], 'devtable', 'password', 200, diff --git a/endpoints/v2/v2auth.py b/endpoints/v2/v2auth.py index 18f10f5dc..bceb14e55 100644 --- a/endpoints/v2/v2auth.py +++ b/endpoints/v2/v2auth.py @@ -13,7 +13,8 @@ from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermissi CreateRepositoryPermission, AdministerRepositoryPermission) from endpoints.decorators import anon_protect from endpoints.v2 import v2_bp -from endpoints.v2.errors import InvalidLogin, NameInvalid, InvalidRequest, Unsupported, Unauthorized +from endpoints.v2.errors import (InvalidLogin, NameInvalid, InvalidRequest, Unsupported, + Unauthorized, NamespaceDisabled) from endpoints.v2.models_pre_oci import data_model as model from util.cache import no_cache from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX @@ -160,6 +161,11 @@ def _authorize_or_downscope_request(scope_param, has_valid_auth_context): raise NameInvalid(message='Invalid repository name: %s' % namespace_and_repo) + # Ensure the namespace is enabled. + if not model.is_namespace_enabled(namespace): + msg = 'Namespace %s has been disabled. Please contact a system administrator.' % namespace + raise NamespaceDisabled(message=msg) + final_actions = [] repo = model.get_repository(namespace, reponame) diff --git a/test/registry/fixtures.py b/test/registry/fixtures.py index bb079d56f..04781f40e 100644 --- a/test/registry/fixtures.py +++ b/test/registry/fixtures.py @@ -99,6 +99,12 @@ def registry_server_executor(app): model.repository.create_repository(namespace, name, user, repo_kind='application') return 'OK' + def disable_namespace(namespace): + namespace_obj = model.user.get_namespace_user(namespace) + namespace_obj.enabled = False + namespace_obj.save() + return 'OK' + executor = LiveServerExecutor() executor.register('generate_csrf', generate_csrf) executor.register('set_supports_direct_download', set_supports_direct_download) @@ -111,6 +117,7 @@ def registry_server_executor(app): executor.register('break_database', break_database) executor.register('reload_app', reload_app) executor.register('create_app_repository', create_app_repository) + executor.register('disable_namespace', disable_namespace) return executor diff --git a/test/registry/protocol_v1.py b/test/registry/protocol_v1.py index a3da149b2..4404024a1 100644 --- a/test/registry/protocol_v1.py +++ b/test/registry/protocol_v1.py @@ -23,6 +23,7 @@ class V1Protocol(RegistryProtocol): Failures.APP_REPOSITORY: 405, Failures.INVALID_REPOSITORY: 404, Failures.DISALLOWED_LIBRARY_NAMESPACE: 400, + Failures.NAMESPACE_DISABLED: 400, }, V1ProtocolSteps.GET_IMAGES: { Failures.UNAUTHENTICATED: 403, @@ -30,11 +31,13 @@ class V1Protocol(RegistryProtocol): Failures.APP_REPOSITORY: 405, Failures.ANONYMOUS_NOT_ALLOWED: 401, Failures.DISALLOWED_LIBRARY_NAMESPACE: 400, + Failures.NAMESPACE_DISABLED: 400, }, V1ProtocolSteps.PUT_TAG: { Failures.MISSING_TAG: 404, Failures.INVALID_TAG: 400, Failures.INVALID_IMAGES: 400, + Failures.NAMESPACE_DISABLED: 400, }, } diff --git a/test/registry/protocol_v2.py b/test/registry/protocol_v2.py index 9c4be8dfb..b1650537f 100644 --- a/test/registry/protocol_v2.py +++ b/test/registry/protocol_v2.py @@ -25,6 +25,7 @@ class V2Protocol(RegistryProtocol): Failures.APP_REPOSITORY: 405, Failures.ANONYMOUS_NOT_ALLOWED: 401, Failures.INVALID_REPOSITORY: 400, + Failures.NAMESPACE_DISABLED: 400, }, V2ProtocolSteps.GET_MANIFEST: { Failures.UNKNOWN_TAG: 404, diff --git a/test/registry/protocols.py b/test/registry/protocols.py index 3b4edba33..d4dd6c145 100644 --- a/test/registry/protocols.py +++ b/test/registry/protocols.py @@ -50,6 +50,7 @@ class Failures(Enum): INVALID_IMAGES = 'invalid-images' UNSUPPORTED_CONTENT_TYPE = 'unsupported-content-type' INVALID_BLOB = 'invalid-blob' + NAMESPACE_DISABLED = 'namespace-disabled' class ProtocolOptions(object): diff --git a/test/registry/registry_tests.py b/test/registry/registry_tests.py index 030528075..6d3529893 100644 --- a/test/registry/registry_tests.py +++ b/test/registry/registry_tests.py @@ -644,6 +644,36 @@ def test_chunked_uploading_mismatched_chunks(manifest_protocol, random_layer_dat images, credentials=credentials, options=options) +def test_pull_disabled_namespace(pusher, puller, basic_images, liveserver_session, + app_reloader, liveserver, registry_server_executor): + """ Test: Attempt to pull a repository from a disabled namespace results in an error. """ + credentials = ('devtable', 'password') + + # Push a new repository. + pusher.push(liveserver_session, 'buynlarge', 'someneworgrepo', 'latest', basic_images, + credentials=credentials) + + # Disable the namespace. + registry_server_executor.on(liveserver).disable_namespace('buynlarge') + + # Attempt to pull, which should fail. + puller.pull(liveserver_session, 'buynlarge', 'someneworgrepo', 'latest', basic_images, + credentials=credentials, expected_failure=Failures.NAMESPACE_DISABLED) + + +def test_push_disabled_namespace(pusher, basic_images, liveserver_session, + app_reloader, liveserver, registry_server_executor): + """ Test: Attempt to push a repository from a disabled namespace results in an error. """ + credentials = ('devtable', 'password') + + # Disable the namespace. + registry_server_executor.on(liveserver).disable_namespace('buynlarge') + + # Attempt to push, which should fail. + pusher.push(liveserver_session, 'buynlarge', 'someneworgrepo', 'latest', basic_images, + credentials=credentials, expected_failure=Failures.NAMESPACE_DISABLED) + + @pytest.mark.parametrize('public_catalog, credentials, expected_repos', [ # No public access and no credentials => No results. (False, None, None),