Add a feature flag for disabling unauthenticated access to the registry in its entirety.

This commit is contained in:
Joseph Schorr 2015-05-19 17:52:44 -04:00
parent 598fc6ec46
commit 54992c23b7
15 changed files with 147 additions and 25 deletions

View file

@ -136,6 +136,9 @@ class DefaultConfig(object):
# Feature Flag: Whether super users are supported. # Feature Flag: Whether super users are supported.
FEATURE_SUPER_USERS = True 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 Flag: Whether billing is required.
FEATURE_BILLING = False FEATURE_BILLING = False

View file

@ -19,6 +19,7 @@ from auth import scopes
from auth.auth_context import get_authenticated_user, get_validated_oauth_token from auth.auth_context import get_authenticated_user, get_validated_oauth_token
from auth.auth import process_oauth from auth.auth import process_oauth
from endpoints.csrf import csrf_protect from endpoints.csrf import csrf_protect
from endpoints.decorators import anon_protect
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -228,12 +229,14 @@ def parse_repository_name(func):
class ApiResource(Resource): class ApiResource(Resource):
method_decorators = [anon_protect]
def options(self): def options(self):
return None, 200 return None, 200
class RepositoryParamResource(ApiResource): 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): def require_repo_permission(permission_class, scope, allow_public=False):

View file

@ -15,6 +15,7 @@ from endpoints.api import (ApiResource, nickname, resource, validate_json_reques
RepositoryParamResource) RepositoryParamResource)
from endpoints.api.subscribe import subscribe from endpoints.api.subscribe import subscribe
from endpoints.common import common_login from endpoints.common import common_login
from endpoints.decorators import anon_allowed
from endpoints.api.team import try_accept_invite from endpoints.api.team import try_accept_invite
from data import model from data import model
@ -203,6 +204,7 @@ class User(ApiResource):
@require_scope(scopes.READ_USER) @require_scope(scopes.READ_USER)
@nickname('getLoggedInUser') @nickname('getLoggedInUser')
@define_json_response('UserView') @define_json_response('UserView')
@anon_allowed
def get(self): def get(self):
""" Get user information for the authenticated user. """ """ Get user information for the authenticated user. """
user = get_authenticated_user() user = get_authenticated_user()
@ -498,6 +500,7 @@ class Signin(ApiResource):
@nickname('signinUser') @nickname('signinUser')
@validate_json_request('SigninUser') @validate_json_request('SigninUser')
@anon_allowed
def post(self): def post(self):
""" Sign in the user with the specified credentials. """ """ Sign in the user with the specified credentials. """
signin_data = request.get_json() signin_data = request.get_json()

30
endpoints/decorators.py Normal file
View file

@ -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

View file

@ -20,6 +20,7 @@ from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission,
from util.http import abort from util.http import abort
from endpoints.trackhelper import track_and_log from endpoints.trackhelper import track_and_log
from endpoints.notificationhelper import spawn_notification from endpoints.notificationhelper import spawn_notification
from endpoints.decorators import anon_protect
import features import features
@ -278,6 +279,7 @@ def update_images(namespace, repository):
@process_auth @process_auth
@parse_repository_name @parse_repository_name
@generate_headers(scope=GrantType.READ_REPOSITORY) @generate_headers(scope=GrantType.READ_REPOSITORY)
@anon_protect
def get_repository_images(namespace, repository): def get_repository_images(namespace, repository):
permission = ReadRepositoryPermission(namespace, repository) permission = ReadRepositoryPermission(namespace, repository)
@ -315,6 +317,7 @@ def put_repository_auth(namespace, repository):
@index.route('/search', methods=['GET']) @index.route('/search', methods=['GET'])
@process_auth @process_auth
@anon_protect
def get_search(): def get_search():
def result_view(repo): def result_view(repo):
return { return {

View file

@ -16,6 +16,7 @@ from auth.permissions import (ReadRepositoryPermission,
ModifyRepositoryPermission) ModifyRepositoryPermission)
from data import model, database from data import model, database
from util import gzipstream from util import gzipstream
from endpoints.decorators import anon_protect
registry = Blueprint('registry', __name__) registry = Blueprint('registry', __name__)
@ -97,6 +98,7 @@ def set_cache_headers(f):
@extract_namespace_repo_from_session @extract_namespace_repo_from_session
@require_completion @require_completion
@set_cache_headers @set_cache_headers
@anon_protect
def head_image_layer(namespace, repository, image_id, headers): def head_image_layer(namespace, repository, image_id, headers):
permission = ReadRepositoryPermission(namespace, repository) permission = ReadRepositoryPermission(namespace, repository)
@ -130,6 +132,7 @@ def head_image_layer(namespace, repository, image_id, headers):
@extract_namespace_repo_from_session @extract_namespace_repo_from_session
@require_completion @require_completion
@set_cache_headers @set_cache_headers
@anon_protect
def get_image_layer(namespace, repository, image_id, headers): def get_image_layer(namespace, repository, image_id, headers):
permission = ReadRepositoryPermission(namespace, repository) permission = ReadRepositoryPermission(namespace, repository)
@ -352,6 +355,7 @@ def put_image_checksum(namespace, repository, image_id):
@extract_namespace_repo_from_session @extract_namespace_repo_from_session
@require_completion @require_completion
@set_cache_headers @set_cache_headers
@anon_protect
def get_image_json(namespace, repository, image_id, headers): def get_image_json(namespace, repository, image_id, headers):
logger.debug('Checking repo permissions') logger.debug('Checking repo permissions')
permission = ReadRepositoryPermission(namespace, repository) permission = ReadRepositoryPermission(namespace, repository)
@ -383,6 +387,7 @@ def get_image_json(namespace, repository, image_id, headers):
@extract_namespace_repo_from_session @extract_namespace_repo_from_session
@require_completion @require_completion
@set_cache_headers @set_cache_headers
@anon_protect
def get_image_ancestry(namespace, repository, image_id, headers): def get_image_ancestry(namespace, repository, image_id, headers):
logger.debug('Checking repo permissions') logger.debug('Checking repo permissions')
permission = ReadRepositoryPermission(namespace, repository) permission = ReadRepositoryPermission(namespace, repository)

View file

@ -10,6 +10,7 @@ from auth.auth import process_auth
from auth.permissions import (ReadRepositoryPermission, from auth.permissions import (ReadRepositoryPermission,
ModifyRepositoryPermission) ModifyRepositoryPermission)
from data import model from data import model
from endpoints.decorators import anon_protect
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,6 +21,7 @@ tags = Blueprint('tags', __name__)
@tags.route('/repositories/<path:repository>/tags', @tags.route('/repositories/<path:repository>/tags',
methods=['GET']) methods=['GET'])
@process_auth @process_auth
@anon_protect
@parse_repository_name @parse_repository_name
def get_tags(namespace, repository): def get_tags(namespace, repository):
permission = ReadRepositoryPermission(namespace, repository) permission = ReadRepositoryPermission(namespace, repository)
@ -35,6 +37,7 @@ def get_tags(namespace, repository):
@tags.route('/repositories/<path:repository>/tags/<tag>', @tags.route('/repositories/<path:repository>/tags/<tag>',
methods=['GET']) methods=['GET'])
@process_auth @process_auth
@anon_protect
@parse_repository_name @parse_repository_name
def get_tag(namespace, repository, tag): def get_tag(namespace, repository, tag):
permission = ReadRepositoryPermission(namespace, repository) permission = ReadRepositoryPermission(namespace, repository)

View file

@ -10,6 +10,7 @@ from auth.permissions import ReadRepositoryPermission
from data import model from data import model
from data import database from data import database
from endpoints.trackhelper import track_and_log from endpoints.trackhelper import track_and_log
from endpoints.decorators import anon_protect
from storage import Storage from storage import Storage
from util.queuefile import QueueFile from util.queuefile import QueueFile
@ -256,6 +257,7 @@ def os_arch_checker(os, arch):
return checker return checker
@anon_protect
@verbs.route('/aci/<server>/<namespace>/<repository>/<tag>/sig/<os>/<arch>/', methods=['GET']) @verbs.route('/aci/<server>/<namespace>/<repository>/<tag>/sig/<os>/<arch>/', methods=['GET'])
@verbs.route('/aci/<server>/<namespace>/<repository>/<tag>/aci.asc/<os>/<arch>/', methods=['GET']) @verbs.route('/aci/<server>/<namespace>/<repository>/<tag>/aci.asc/<os>/<arch>/', methods=['GET'])
@process_auth @process_auth
@ -264,6 +266,7 @@ def get_aci_signature(server, namespace, repository, tag, os, arch):
os=os, arch=arch) os=os, arch=arch)
@anon_protect
@verbs.route('/aci/<server>/<namespace>/<repository>/<tag>/aci/<os>/<arch>/', methods=['GET']) @verbs.route('/aci/<server>/<namespace>/<repository>/<tag>/aci/<os>/<arch>/', methods=['GET'])
@process_auth @process_auth
def get_aci_image(server, namespace, repository, tag, os, arch): 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) sign=True, checker=os_arch_checker(os, arch), os=os, arch=arch)
@anon_protect
@verbs.route('/squash/<namespace>/<repository>/<tag>', methods=['GET']) @verbs.route('/squash/<namespace>/<repository>/<tag>', methods=['GET'])
@process_auth @process_auth
def get_squashed_tag(namespace, repository, tag): def get_squashed_tag(namespace, repository, tag):

View file

@ -18,6 +18,7 @@ from util.invoice import renderInvoiceToPdf
from util.seo import render_snapshot from util.seo import render_snapshot
from util.cache import no_cache from util.cache import no_cache
from endpoints.common import common_login, render_page_template, route_show_if, param_required 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.csrf import csrf_protect, generate_csrf_token, verify_csrf
from endpoints.registry import set_cache_headers from endpoints.registry import set_cache_headers
from endpoints.trigger import (CustomBuildTrigger, BitbucketBuildTrigger, TriggerProviderException, from endpoints.trigger import (CustomBuildTrigger, BitbucketBuildTrigger, TriggerProviderException,
@ -79,6 +80,7 @@ def snapshot(path = ''):
@web.route('/aci-signing-key') @web.route('/aci-signing-key')
@no_cache @no_cache
@anon_protect
def aci_signing_key(): def aci_signing_key():
if not signer.name: if not signer.name:
abort(404) abort(404)
@ -337,6 +339,7 @@ def confirm_recovery():
@web.route('/repository/<path:repository>/status', methods=['GET']) @web.route('/repository/<path:repository>/status', methods=['GET'])
@parse_repository_name @parse_repository_name
@no_cache @no_cache
@anon_protect
def build_status_badge(namespace, repository): def build_status_badge(namespace, repository):
token = request.args.get('token', None) token = request.args.get('token', None)
is_public = model.repository_is_public(namespace, repository) is_public = model.repository_is_public(namespace, repository)
@ -565,6 +568,7 @@ def attach_custom_build_trigger(namespace, repository_name):
@no_cache @no_cache
@process_oauth @process_oauth
@parse_repository_name_and_tag @parse_repository_name_and_tag
@anon_protect
def redirect_to_repository(namespace, reponame, tag): def redirect_to_repository(namespace, reponame, tag):
permission = ReadRepositoryPermission(namespace, reponame) permission = ReadRepositoryPermission(namespace, reponame)
is_public = model.repository_is_public(namespace, reponame) is_public = model.repository_is_public(namespace, reponame)
@ -582,6 +586,7 @@ def redirect_to_repository(namespace, reponame, tag):
@web.route('/<namespace>') @web.route('/<namespace>')
@no_cache @no_cache
@process_oauth @process_oauth
@anon_protect
def redirect_to_namespace(namespace): def redirect_to_namespace(namespace):
user_or_org = model.get_user_or_org(namespace) user_or_org = model.get_user_or_org(namespace)
if not user_or_org: if not user_or_org:

View file

@ -33,6 +33,20 @@
</div> </div>
</td> </td>
</tr> </tr>
<tr>
<td class="non-input">Anonymous Access:</td>
<td colspan="2">
<div class="co-checkbox">
<input id="ftaa" type="checkbox" ng-model="config.FEATURE_ANONYMOUS_ACCESS">
<label for="ftaa">Enable Anonymous Access</label>
</div>
<div class="help-text">
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.
</div>
</td>
</tr>
<tr> <tr>
<td class="non-input">User Creation:</td> <td class="non-input">User Creation:</td>
<td colspan="2"> <td colspan="2">

View file

@ -10,7 +10,8 @@
</a> </a>
<span class="user-tools visible-xs" style="float: right;"> <span class="user-tools visible-xs" style="float: right;">
<i class="fa fa-search fa-lg user-tool" ng-click="toggleSearch()" <i class="fa fa-search fa-lg user-tool" ng-click="toggleSearch()"
data-placement="bottom" data-title="Search" bs-tooltip></i> data-placement="bottom" data-title="Search" bs-tooltip
ng-if="searchingAllowed"></i>
</span> </span>
</div> </div>
@ -49,7 +50,8 @@
<li> <li>
<span class="navbar-left user-tools"> <span class="navbar-left user-tools">
<i class="fa fa-search fa-lg user-tool" ng-click="toggleSearch()" <i class="fa fa-search fa-lg user-tool" ng-click="toggleSearch()"
data-placement="bottom" data-title="Search - Keyboard Shortcut: /" bs-tooltip></i> data-placement="bottom" data-title="Search - Keyboard Shortcut: /" bs-tooltip
ng-if="searchingAllowed"></i>
</span> </span>
</li> </li>
<li> <li>

View file

@ -12,40 +12,55 @@ angular.module('quay').directive('headerBar', function () {
restrict: 'C', restrict: 'C',
scope: { 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(); $scope.isNewLayout = Config.isNewLayout();
if ($scope.isNewLayout) { var hotkeysAdded = false;
// Register hotkeys: var userUpdated = function(cUser) {
hotkeys.add({ $scope.searchingAllowed = Features.ANONYMOUS_ACCESS || !cUser.anonymous;
combo: '/',
description: 'Show search',
callback: function(e) {
e.preventDefault();
e.stopPropagation();
$scope.toggleSearch();
}
});
hotkeys.add({ if (hotkeysAdded) { return; }
combo: 'alt+c',
description: 'Create new repository', if ($scope.isNewLayout) {
callback: function(e) { hotkeysAdded = true;
e.preventDefault();
e.stopPropagation(); // Register hotkeys.
$location.url('/new'); 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.notificationService = NotificationService;
$scope.searchingAllowed = false;
$scope.searchVisible = false; $scope.searchVisible = false;
$scope.currentSearchQuery = null; $scope.currentSearchQuery = null;
$scope.searchResultState = null; $scope.searchResultState = null;
$scope.showBuildDialogCounter = 0; $scope.showBuildDialogCounter = 0;
// Monitor any user changes and place the current user into the scope. // Monitor any user changes and place the current user into the scope.
UserService.updateUserIn($scope); UserService.updateUserIn($scope, userUpdated);
$scope.currentPageContext = {}; $scope.currentPageContext = {};

View file

@ -371,6 +371,24 @@ class TestOrganizationList(ApiTestCase):
self._run_test('POST', 400, 'devtable', {u'name': 'KSIS', u'email': 'DHVZ'}) 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): class TestRepositoryList(ApiTestCase):
def setUp(self): def setUp(self):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)

View file

@ -1246,6 +1246,19 @@ class TestListRepos(ApiTestCase):
self.assertEquals(len(json['repositories']), 2) 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): class TestUpdateRepo(ApiTestCase):
SIMPLE_REPO = ADMIN_ACCESS_USER + '/simple' SIMPLE_REPO = ADMIN_ACCESS_USER + '/simple'
def test_updatedescription(self): def test_updatedescription(self):

View file

@ -16,6 +16,7 @@ def add_enterprise_config_defaults(config_obj, current_secret_key, hostname):
# Default features that are on. # Default features that are on.
config_obj['FEATURE_USER_LOG_ACCESS'] = config_obj.get('FEATURE_USER_LOG_ACCESS', True) 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_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. # Default features that are off.
config_obj['FEATURE_MAILING'] = config_obj.get('FEATURE_MAILING', False) config_obj['FEATURE_MAILING'] = config_obj.get('FEATURE_MAILING', False)