Removed database calls and made scope filter a callable

This commit is contained in:
0mar
2021-02-25 07:30:41 +01:00
parent de2e8ff355
commit 1c789fcbb5
5 changed files with 128 additions and 80 deletions

View File

@@ -2,6 +2,7 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
import re
from datetime import datetime
from http.client import responses
@@ -9,6 +10,7 @@ from sqlalchemy.exc import SQLAlchemyError
from tornado import web
from .. import orm
from .. import roles
from ..handlers import BaseHandler
from ..handlers import scopes
from ..utils import isoformat
@@ -24,8 +26,27 @@ class APIHandler(BaseHandler):
- strict referer checking for Cookie-authenticated requests
- strict content-security-policy
- methods for REST API models
- scope loading
"""
async def prepare(self):
await super().prepare()
self.raw_scopes = set()
self.parsed_scopes = {}
self._parse_scopes()
def _parse_scopes(self):
"""Parse raw scope collection into a dict with filters that can be used to resolve API access"""
self.log.debug("Parsing scopes")
if self.current_user is not None:
self.raw_scopes = roles.get_subscopes(*self.current_user.roles)
oauth_token = self.get_current_user_oauth_token()
if oauth_token:
self.raw_scopes |= scopes.get_user_scopes(oauth_token.name)
if 'all' in self.raw_scopes:
self.raw_scopes |= scopes.get_user_scopes(self.current_user.name)
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
@property
def content_security_policy(self):
return '; '.join([super().content_security_policy, "default-src 'none'"])
@@ -64,36 +85,41 @@ class APIHandler(BaseHandler):
return True
def get_scope_filter(self, req_scope):
"""Produce a filter for `*ListAPIHandlers* so that GET method knows which models to return"""
scope_translator = {
'read:users': 'users',
'read:services': 'services',
'read:groups': 'groups',
}
if req_scope not in scope_translator:
raise AttributeError("Internal error: inconsistent scope situation")
kind = scope_translator[req_scope]
Resource = orm.get_class(kind)
"""Produce a filter for `*ListAPIHandlers* so that GET method knows which models to return.
Filter is a callable that takes a resource name and outputs true or false"""
kind_regex = re.compile(r':?(user|service|group)s:?')
try:
kind = re.search(kind_regex, req_scope).group(1)
except AttributeError:
self.log.warning(
"Regex error while processing scope %s, throwing 500", req_scope
)
raise web.HTTPError(
log_message="Unrecognized scope guard on method: %s" % req_scope
)
try:
sub_scope = self.parsed_scopes[req_scope]
except AttributeError:
raise web.HTTPError(
403,
"Resource scope %s (that was just accessed) not found in scopes anymore"
"Resource scope %s (that was just accessed) not found in parsed scope model"
% req_scope,
)
if sub_scope == scopes.Scope.ALL:
return None # Full access
sub_scope_values = next(iter(sub_scope.values()))
query = self.db.query(Resource).filter(Resource.name.in_(sub_scope_values))
scope_filter = {entry.name for entry in query.all()}
if 'group' in sub_scope and kind == 'users':
groups = orm.Group.name.in_(sub_scope['group'])
users_in_groups = (
self.db.query(orm.User).join(orm.Group.users).filter(groups)
)
scope_filter |= {user.name for user in users_in_groups}
return scope_filter
def has_access(resource_name):
if sub_scope == scopes.Scope.ALL:
found_resource = True
else:
found_resource = resource_name in sub_scope[kind]
if not found_resource: # Try group-based access
if 'groups' in sub_scope and kind == 'users':
user = self.current_user()
if user:
user_in_group = bool(user.groups & sub_scope['groups'])
found_resource |= user_in_group
return found_resource
return has_access
def get_current_user_cookie(self):
"""Override get_user_cookie to check Referer header"""
@@ -234,24 +260,12 @@ class APIHandler(BaseHandler):
'last_activity': isoformat(user.last_activity),
}
access_map = {
'read:users': {
'kind',
'name',
'admin',
'roles',
'groups',
'server',
'servers',
'pending',
'created',
'last_activity',
},
'read:users:name': {'kind', 'name'},
'read:users': set(model.keys()), # All available components
'read:users:names': {'kind', 'name'},
'read:users:groups': {'kind', 'name', 'groups'},
'read:users:activity': {'kind', 'name', 'last_activity'},
'read:users:servers': {'kind', 'name', 'servers'},
}
# Todo: Should 'name' be included in all access?
self.log.debug(
"Asking for user models with scopes [%s]" % ", ".join(self.raw_scopes)
)
@@ -259,24 +273,23 @@ class APIHandler(BaseHandler):
for scope in access_map:
if scope in self.parsed_scopes:
scope_filter = self.get_scope_filter(scope)
if scope_filter is None or user.name in scope_filter:
if scope_filter(user.name):
allowed_keys |= access_map[scope]
model = {key: model[key] for key in allowed_keys if key in model}
if not model:
return model # No access to this user
if '' in user.spawners and 'pending' in allowed_keys:
model['pending'] = user.spawners[''].pending
if not (include_servers and 'servers' in allowed_keys):
model['servers'] = None
else:
servers = model['servers'] = {}
for name, spawner in user.spawners.items():
# include 'active' servers, not just ready
# (this includes pending events)
if spawner.active:
servers[name] = self.server_model(
spawner, include_state=include_state
)
if model:
if '' in user.spawners and 'pending' in allowed_keys:
model['pending'] = user.spawners[''].pending
if not (include_servers and 'servers' in allowed_keys):
model['servers'] = None
else:
servers = model['servers'] = {}
for name, spawner in user.spawners.items():
# include 'active' servers, not just ready
# (this includes pending events)
if spawner.active:
servers[name] = self.server_model(
spawner, include_state=include_state
)
return model
def group_model(self, group):

View File

@@ -38,10 +38,8 @@ class GroupListAPIHandler(_GroupAPIHandler):
def get(self):
"""List groups"""
groups = self.db.query(orm.Group)
scope_filter = self.get_scope_filter(self.db)
if scope_filter is not None:
groups = groups.filter(orm.Group.name.in_(scope_filter))
data = [self.group_model(g) for g in groups]
scope_filter = self.get_scope_filter('read:groups')
data = [self.group_model(g) for g in groups if scope_filter(g)]
self.write(json.dumps(data))
@needs_scope('admin:groups')

View File

@@ -81,8 +81,6 @@ class BaseHandler(RequestHandler):
The current user (None if not logged in) may be accessed
via the `self.current_user` property during the handling of any request.
"""
self.raw_scopes = set()
self.parsed_scopes = set()
try:
await self.get_current_user()
except Exception:
@@ -431,16 +429,8 @@ class BaseHandler(RequestHandler):
# don't let errors here raise more than once
self._jupyterhub_user = None
self.log.exception("Error getting current user")
self._parse_scopes()
return self._jupyterhub_user
def _parse_scopes(self):
if self._jupyterhub_user is not None or self.get_current_user_oauth_token():
self.raw_scopes = roles.get_subscopes(*self._jupyterhub_user.roles)
if 'all' in self.raw_scopes:
self.raw_scopes |= scopes.get_user_scopes(self.current_user.name)
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
@property
def current_user(self):
"""Override .current_user accessor from tornado

View File

@@ -1,19 +1,18 @@
import functools
import inspect
import re
from enum import Enum
from tornado import web
from tornado.log import app_log
from . import orm
from . import roles
class Scope(Enum):
ALL = True
def get_user_scopes(name):
def get_user_scopes(name, read_only=False):
"""
Scopes have a metascope 'all' that should be expanded to everything a user can do.
At the moment that is a user-filtered version (optional read) access to
@@ -32,7 +31,11 @@ def get_user_scopes(name):
'users:servers',
'users:tokens',
]
scope_list.extend(['read:' + scope for scope in scope_list])
read_scope_list = ['read:' + scope for scope in scope_list]
if read_only:
scope_list = read_scope_list
else:
scope_list.extend(read_scope_list)
return {"{}!user={}".format(scope, name) for scope in scope_list}
@@ -55,7 +58,7 @@ def _check_user_in_expanded_scope(handler, user_name, scope_group_names):
user = handler.find_user(user_name)
if user is None:
raise web.HTTPError(404, "No access to resources or resources not found")
group_names = {group.name for group in user.groups} # SQL query faster?
group_names = {group.name for group in user.groups}
return bool(set(scope_group_names) & group_names)
@@ -67,7 +70,7 @@ def _check_scope(api_handler, req_scope, **kwargs):
# Parse user name and server name together
try:
api_name = api_handler.request.path
except:
except AttributeError:
api_name = type(api_handler).__name__
if 'user' in kwargs and 'server' in kwargs:
kwargs['server'] = "{}/{}".format(kwargs['user'], kwargs['server'])
@@ -147,6 +150,14 @@ def needs_scope(*scopes):
bound_sig = sig.bind(self, *args, **kwargs)
bound_sig.apply_defaults()
s_kwargs = {}
# current_user = self.current_user()
# if current_user is not None or self.get_current_user_oauth_token():
# self.raw_scopes = roles.get_subscopes(*current_user.roles)
# if 'all' in self.raw_scopes:
# self.raw_scopes |= get_user_scopes(self.current_user.name)
# self.parsed_scopes = parse_scopes(self.raw_scopes)
# else:
# app_log.warning("No user found in access checking, so no scopes loaded")
for resource in {'user', 'server', 'group', 'service'}:
resource_name = resource + '_name'
if resource_name in bound_sig.arguments:

View File

@@ -54,7 +54,7 @@ def test_scope_check_present():
def test_scope_check_not_present():
handler = get_handler_with_scopes(['read:users!user=maeby'])
assert not _check_scope(handler, 'read:users')
assert _check_scope(handler, 'read:users')
with pytest.raises(web.HTTPError):
_check_scope(handler, 'read:users', user='gob')
with pytest.raises(web.HTTPError):
@@ -103,7 +103,8 @@ class MockAPIHandler:
return True
@needs_scope('users')
def other_thing(self, other_name):
def other_thing(self, non_filter_argument):
# Rely on inner vertical filtering
return True
@needs_scope('users')
@@ -161,8 +162,8 @@ class MockAPIHandler:
),
(['users'], 'other_thing', ('gob',), True),
(['read:users'], 'other_thing', ('gob',), False),
(['users!user=gob'], 'other_thing', ('gob',), False),
(['users!user=gob'], 'other_thing', ('maeby',), False),
(['users!user=gob'], 'other_thing', ('gob',), True),
(['users!user=gob'], 'other_thing', ('maeby',), True),
],
)
def test_scope_method_access(scopes, method, arguments, is_allowed):
@@ -403,12 +404,47 @@ async def test_vertical_filter(app):
r = await api_request(app, 'users', headers=auth_header(app.db, user_name))
assert r.status_code == 200
assert set(r.json().keys()) == {'names'}
allowed_keys = {'name', 'kind'}
assert set([key for user in r.json() for key in user.keys()]) == allowed_keys
async def test_stacked_vertical_filter(app):
pass
user_name = 'user'
test_role = generate_test_role(
user_name, ['read:users:activity', 'read:users:servers']
)
roles.add_role(app.db, test_role)
roles.add_obj(app.db, objname=user_name, kind='users', rolename='test')
roles.remove_obj(app.db, objname=user_name, kind='users', rolename='user')
app.db.commit()
r = await api_request(app, 'users', headers=auth_header(app.db, user_name))
assert r.status_code == 200
allowed_keys = {'name', 'kind', 'servers', 'last_activity'}
result_model = set([key for user in r.json() for key in user.keys()])
assert result_model == allowed_keys
async def test_cross_filter(app):
pass
user_name = 'abed'
add_user(app.db, name=user_name)
test_role = generate_test_role(
user_name, ['read:users:activity', 'read:users!user=abed']
)
roles.add_role(app.db, test_role)
roles.add_obj(app.db, objname=user_name, kind='users', rolename='test')
roles.remove_obj(app.db, objname=user_name, kind='users', rolename='user')
app.db.commit()
new_users = {'britta', 'jeff', 'annie'}
for new_user_name in new_users:
add_user(app.db, name=new_user_name)
app.db.commit()
r = await api_request(app, 'users', headers=auth_header(app.db, user_name))
assert r.status_code == 200
restricted_keys = {'name', 'kind', 'last_activity'}
key_in_full_model = 'created'
for user in r.json():
if user['name'] == user_name:
assert key_in_full_model in user
else:
assert set(user.keys()) == restricted_keys