Add a feature flag for disabling unauthenticated access to the registry in its entirety.
This commit is contained in:
parent
598fc6ec46
commit
54992c23b7
15 changed files with 147 additions and 25 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
30
endpoints/decorators.py
Normal file
30
endpoints/decorators.py
Normal 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
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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/<path:repository>/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/<path:repository>/tags/<tag>',
|
||||
methods=['GET'])
|
||||
@process_auth
|
||||
@anon_protect
|
||||
@parse_repository_name
|
||||
def get_tag(namespace, repository, tag):
|
||||
permission = ReadRepositoryPermission(namespace, repository)
|
||||
|
|
|
@ -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/<server>/<namespace>/<repository>/<tag>/sig/<os>/<arch>/', methods=['GET'])
|
||||
@verbs.route('/aci/<server>/<namespace>/<repository>/<tag>/aci.asc/<os>/<arch>/', 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/<server>/<namespace>/<repository>/<tag>/aci/<os>/<arch>/', 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/<namespace>/<repository>/<tag>', methods=['GET'])
|
||||
@process_auth
|
||||
def get_squashed_tag(namespace, repository, tag):
|
||||
|
|
|
@ -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/<path: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('/<namespace>')
|
||||
@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:
|
||||
|
|
|
@ -33,6 +33,20 @@
|
|||
</div>
|
||||
</td>
|
||||
</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>
|
||||
<td class="non-input">User Creation:</td>
|
||||
<td colspan="2">
|
||||
|
|
|
@ -10,7 +10,8 @@
|
|||
</a>
|
||||
<span class="user-tools visible-xs" style="float: right;">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
@ -49,7 +50,8 @@
|
|||
<li>
|
||||
<span class="navbar-left user-tools">
|
||||
<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>
|
||||
</li>
|
||||
<li>
|
||||
|
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
Reference in a new issue