From 54992c23b74b5cbd6ef0ff858bc2090e53a84031 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 19 May 2015 17:52:44 -0400 Subject: [PATCH] Add a feature flag for disabling unauthenticated access to the registry in its entirety. --- config.py | 3 + endpoints/api/__init__.py | 5 +- endpoints/api/user.py | 3 + endpoints/decorators.py | 30 ++++++++++ endpoints/index.py | 3 + endpoints/registry.py | 5 ++ endpoints/tags.py | 3 + endpoints/verbs.py | 4 ++ endpoints/web.py | 5 ++ .../directives/config/config-setup-tool.html | 14 +++++ static/directives/new-header-bar.html | 6 +- static/js/directives/ui/header-bar.js | 59 ++++++++++++------- test/test_api_security.py | 18 ++++++ test/test_api_usage.py | 13 ++++ util/config/configutil.py | 1 + 15 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 endpoints/decorators.py diff --git a/config.py b/config.py index 6bb7d0126..ff713b290 100644 --- a/config.py +++ b/config.py @@ -136,6 +136,9 @@ class DefaultConfig(object): # Feature Flag: Whether super users are supported. FEATURE_SUPER_USERS = True + # Feature Flag: Whether to allow anonymous users to browse and pull public repositories. + FEATURE_ANONYMOUS_ACCESS = True + # Feature Flag: Whether billing is required. FEATURE_BILLING = False diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 0252851a1..d55e99632 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -19,6 +19,7 @@ from auth import scopes from auth.auth_context import get_authenticated_user, get_validated_oauth_token from auth.auth import process_oauth from endpoints.csrf import csrf_protect +from endpoints.decorators import anon_protect logger = logging.getLogger(__name__) @@ -228,12 +229,14 @@ def parse_repository_name(func): class ApiResource(Resource): + method_decorators = [anon_protect] + def options(self): return None, 200 class RepositoryParamResource(ApiResource): - method_decorators = [parse_repository_name] + method_decorators = [anon_protect, parse_repository_name] def require_repo_permission(permission_class, scope, allow_public=False): diff --git a/endpoints/api/user.py b/endpoints/api/user.py index b03c5f87b..92199ec68 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -15,6 +15,7 @@ from endpoints.api import (ApiResource, nickname, resource, validate_json_reques RepositoryParamResource) from endpoints.api.subscribe import subscribe from endpoints.common import common_login +from endpoints.decorators import anon_allowed from endpoints.api.team import try_accept_invite from data import model @@ -203,6 +204,7 @@ class User(ApiResource): @require_scope(scopes.READ_USER) @nickname('getLoggedInUser') @define_json_response('UserView') + @anon_allowed def get(self): """ Get user information for the authenticated user. """ user = get_authenticated_user() @@ -498,6 +500,7 @@ class Signin(ApiResource): @nickname('signinUser') @validate_json_request('SigninUser') + @anon_allowed def post(self): """ Sign in the user with the specified credentials. """ signin_data = request.get_json() diff --git a/endpoints/decorators.py b/endpoints/decorators.py new file mode 100644 index 000000000..3b9c60879 --- /dev/null +++ b/endpoints/decorators.py @@ -0,0 +1,30 @@ +""" Various decorators for endpoint and API handlers. """ + +import features +from flask import abort +from auth.auth_context import (get_validated_oauth_token, get_authenticated_user, + get_validated_token, get_grant_user_context) +from functools import wraps + + +def anon_allowed(func): + """ Marks a method to allow anonymous access where it would otherwise be disallowed. """ + func.__anon_allowed = True + return func + + +def anon_protect(func): + """ Marks a method as requiring some form of valid user auth before it can be executed. """ + @wraps(func) + def wrapper(*args, **kwargs): + # Skip if anonymous access is allowed. + if features.ANONYMOUS_ACCESS or '__anon_allowed' in dir(func): + return func(*args, **kwargs) + + # Check for validated context. If none exists, fail with a 401. + if (get_authenticated_user() or get_validated_oauth_token() or get_validated_token() or + get_grant_user_context()): + return func(*args, **kwargs) + + abort(401) + return wrapper \ No newline at end of file diff --git a/endpoints/index.py b/endpoints/index.py index 27427f571..bb3413a76 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -20,6 +20,7 @@ from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission, from util.http import abort from endpoints.trackhelper import track_and_log from endpoints.notificationhelper import spawn_notification +from endpoints.decorators import anon_protect import features @@ -278,6 +279,7 @@ def update_images(namespace, repository): @process_auth @parse_repository_name @generate_headers(scope=GrantType.READ_REPOSITORY) +@anon_protect def get_repository_images(namespace, repository): permission = ReadRepositoryPermission(namespace, repository) @@ -315,6 +317,7 @@ def put_repository_auth(namespace, repository): @index.route('/search', methods=['GET']) @process_auth +@anon_protect def get_search(): def result_view(repo): return { diff --git a/endpoints/registry.py b/endpoints/registry.py index 73610910e..89ba9bd89 100644 --- a/endpoints/registry.py +++ b/endpoints/registry.py @@ -16,6 +16,7 @@ from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission) from data import model, database from util import gzipstream +from endpoints.decorators import anon_protect registry = Blueprint('registry', __name__) @@ -97,6 +98,7 @@ def set_cache_headers(f): @extract_namespace_repo_from_session @require_completion @set_cache_headers +@anon_protect def head_image_layer(namespace, repository, image_id, headers): permission = ReadRepositoryPermission(namespace, repository) @@ -130,6 +132,7 @@ def head_image_layer(namespace, repository, image_id, headers): @extract_namespace_repo_from_session @require_completion @set_cache_headers +@anon_protect def get_image_layer(namespace, repository, image_id, headers): permission = ReadRepositoryPermission(namespace, repository) @@ -352,6 +355,7 @@ def put_image_checksum(namespace, repository, image_id): @extract_namespace_repo_from_session @require_completion @set_cache_headers +@anon_protect def get_image_json(namespace, repository, image_id, headers): logger.debug('Checking repo permissions') permission = ReadRepositoryPermission(namespace, repository) @@ -383,6 +387,7 @@ def get_image_json(namespace, repository, image_id, headers): @extract_namespace_repo_from_session @require_completion @set_cache_headers +@anon_protect def get_image_ancestry(namespace, repository, image_id, headers): logger.debug('Checking repo permissions') permission = ReadRepositoryPermission(namespace, repository) diff --git a/endpoints/tags.py b/endpoints/tags.py index 9e61bf030..8911a9b50 100644 --- a/endpoints/tags.py +++ b/endpoints/tags.py @@ -10,6 +10,7 @@ from auth.auth import process_auth from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission) from data import model +from endpoints.decorators import anon_protect logger = logging.getLogger(__name__) @@ -20,6 +21,7 @@ tags = Blueprint('tags', __name__) @tags.route('/repositories//tags', methods=['GET']) @process_auth +@anon_protect @parse_repository_name def get_tags(namespace, repository): permission = ReadRepositoryPermission(namespace, repository) @@ -35,6 +37,7 @@ def get_tags(namespace, repository): @tags.route('/repositories//tags/', methods=['GET']) @process_auth +@anon_protect @parse_repository_name def get_tag(namespace, repository, tag): permission = ReadRepositoryPermission(namespace, repository) diff --git a/endpoints/verbs.py b/endpoints/verbs.py index 88d9dc7b1..cd86b8baa 100644 --- a/endpoints/verbs.py +++ b/endpoints/verbs.py @@ -10,6 +10,7 @@ from auth.permissions import ReadRepositoryPermission from data import model from data import database from endpoints.trackhelper import track_and_log +from endpoints.decorators import anon_protect from storage import Storage from util.queuefile import QueueFile @@ -256,6 +257,7 @@ def os_arch_checker(os, arch): return checker +@anon_protect @verbs.route('/aci/////sig///', methods=['GET']) @verbs.route('/aci/////aci.asc///', methods=['GET']) @process_auth @@ -264,6 +266,7 @@ def get_aci_signature(server, namespace, repository, tag, os, arch): os=os, arch=arch) +@anon_protect @verbs.route('/aci/////aci///', methods=['GET']) @process_auth def get_aci_image(server, namespace, repository, tag, os, arch): @@ -271,6 +274,7 @@ def get_aci_image(server, namespace, repository, tag, os, arch): sign=True, checker=os_arch_checker(os, arch), os=os, arch=arch) +@anon_protect @verbs.route('/squash///', methods=['GET']) @process_auth def get_squashed_tag(namespace, repository, tag): diff --git a/endpoints/web.py b/endpoints/web.py index 884f4e6ea..db12a0e65 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -18,6 +18,7 @@ from util.invoice import renderInvoiceToPdf from util.seo import render_snapshot from util.cache import no_cache from endpoints.common import common_login, render_page_template, route_show_if, param_required +from endpoints.decorators import anon_protect from endpoints.csrf import csrf_protect, generate_csrf_token, verify_csrf from endpoints.registry import set_cache_headers from endpoints.trigger import (CustomBuildTrigger, BitbucketBuildTrigger, TriggerProviderException, @@ -79,6 +80,7 @@ def snapshot(path = ''): @web.route('/aci-signing-key') @no_cache +@anon_protect def aci_signing_key(): if not signer.name: abort(404) @@ -337,6 +339,7 @@ def confirm_recovery(): @web.route('/repository//status', methods=['GET']) @parse_repository_name @no_cache +@anon_protect def build_status_badge(namespace, repository): token = request.args.get('token', None) is_public = model.repository_is_public(namespace, repository) @@ -565,6 +568,7 @@ def attach_custom_build_trigger(namespace, repository_name): @no_cache @process_oauth @parse_repository_name_and_tag +@anon_protect def redirect_to_repository(namespace, reponame, tag): permission = ReadRepositoryPermission(namespace, reponame) is_public = model.repository_is_public(namespace, reponame) @@ -582,6 +586,7 @@ def redirect_to_repository(namespace, reponame, tag): @web.route('/') @no_cache @process_oauth +@anon_protect def redirect_to_namespace(namespace): user_or_org = model.get_user_or_org(namespace) if not user_or_org: diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index 7c2fa2e19..6e9458f61 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -33,6 +33,20 @@ + + Anonymous Access: + +
+ + +
+
+ If enabled, public repositories and search can be accessed by anyone that can + reach the registry, even if they are not authenticated. Disable to only allow + authenticated users to view and pull "public" resources. +
+ + User Creation: diff --git a/static/directives/new-header-bar.html b/static/directives/new-header-bar.html index 805371233..e61be6fc4 100644 --- a/static/directives/new-header-bar.html +++ b/static/directives/new-header-bar.html @@ -10,7 +10,8 @@ + data-placement="bottom" data-title="Search" bs-tooltip + ng-if="searchingAllowed"> @@ -49,7 +50,8 @@
  • + data-placement="bottom" data-title="Search - Keyboard Shortcut: /" bs-tooltip + ng-if="searchingAllowed">
  • diff --git a/static/js/directives/ui/header-bar.js b/static/js/directives/ui/header-bar.js index 956cae711..e5da1073b 100644 --- a/static/js/directives/ui/header-bar.js +++ b/static/js/directives/ui/header-bar.js @@ -12,40 +12,55 @@ angular.module('quay').directive('headerBar', function () { restrict: 'C', scope: { }, - controller: function($rootScope, $scope, $element, $location, $timeout, hotkeys, UserService, PlanService, ApiService, NotificationService, Config, CreateService) { + controller: function($rootScope, $scope, $element, $location, $timeout, hotkeys, UserService, + PlanService, ApiService, NotificationService, Config, CreateService, Features) { $scope.isNewLayout = Config.isNewLayout(); - if ($scope.isNewLayout) { - // Register hotkeys: - hotkeys.add({ - combo: '/', - description: 'Show search', - callback: function(e) { - e.preventDefault(); - e.stopPropagation(); - $scope.toggleSearch(); - } - }); + var hotkeysAdded = false; + var userUpdated = function(cUser) { + $scope.searchingAllowed = Features.ANONYMOUS_ACCESS || !cUser.anonymous; - hotkeys.add({ - combo: 'alt+c', - description: 'Create new repository', - callback: function(e) { - e.preventDefault(); - e.stopPropagation(); - $location.url('/new'); + if (hotkeysAdded) { return; } + + if ($scope.isNewLayout) { + hotkeysAdded = true; + + // Register hotkeys. + if ($scope.searchingAllowed) { + hotkeys.add({ + combo: '/', + description: 'Show search', + callback: function(e) { + e.preventDefault(); + e.stopPropagation(); + $scope.toggleSearch(); + } + }); } - }); - } + + if (!cUser.anonymous) { + hotkeys.add({ + combo: 'alt+c', + description: 'Create new repository', + callback: function(e) { + e.preventDefault(); + e.stopPropagation(); + $location.url('/new'); + } + }); + } + } + }; $scope.notificationService = NotificationService; + $scope.searchingAllowed = false; $scope.searchVisible = false; $scope.currentSearchQuery = null; $scope.searchResultState = null; $scope.showBuildDialogCounter = 0; // Monitor any user changes and place the current user into the scope. - UserService.updateUserIn($scope); + UserService.updateUserIn($scope, userUpdated); $scope.currentPageContext = {}; diff --git a/test/test_api_security.py b/test/test_api_security.py index e7b0f40e2..0b9b0f09d 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -371,6 +371,24 @@ class TestOrganizationList(ApiTestCase): self._run_test('POST', 400, 'devtable', {u'name': 'KSIS', u'email': 'DHVZ'}) +class TestPublicRepository(ApiTestCase): + def setUp(self): + ApiTestCase.setUp(self) + self._set_url(Repository, repository="public/publicrepo") + + def test_get_anonymous(self): + self._run_test('GET', 200, None, None) + + def test_get_freshuser(self): + self._run_test('GET', 200, 'freshuser', None) + + def test_get_reader(self): + self._run_test('GET', 200, 'reader', None) + + def test_get_devtable(self): + self._run_test('GET', 200, 'devtable', None) + + class TestRepositoryList(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) diff --git a/test/test_api_usage.py b/test/test_api_usage.py index c4f3b1349..4683fe05b 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -1246,6 +1246,19 @@ class TestListRepos(ApiTestCase): self.assertEquals(len(json['repositories']), 2) +class TestViewPublicRepository(ApiTestCase): + def test_normalview(self): + self.getJsonResponse(Repository, params=dict(repository='public/publicrepo')) + + def test_anon_access_disabled(self): + import features + features.ANONYMOUS_ACCESS = False + try: + self.getResponse(Repository, params=dict(repository='public/publicrepo'), expected_code=401) + finally: + features.ANONYMOUS_ACCESS = True + + class TestUpdateRepo(ApiTestCase): SIMPLE_REPO = ADMIN_ACCESS_USER + '/simple' def test_updatedescription(self): diff --git a/util/config/configutil.py b/util/config/configutil.py index 7a31390c9..b17958f03 100644 --- a/util/config/configutil.py +++ b/util/config/configutil.py @@ -16,6 +16,7 @@ def add_enterprise_config_defaults(config_obj, current_secret_key, hostname): # Default features that are on. config_obj['FEATURE_USER_LOG_ACCESS'] = config_obj.get('FEATURE_USER_LOG_ACCESS', True) config_obj['FEATURE_USER_CREATION'] = config_obj.get('FEATURE_USER_CREATION', True) + config_obj['FEATURE_ANONYMOUS_ACCESS'] = config_obj.get('FEATURE_ANONYMOUS_ACCESS', True) # Default features that are off. config_obj['FEATURE_MAILING'] = config_obj.get('FEATURE_MAILING', False)