mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 15:33:02 +00:00
Refactored scope module. Implemented filter in *ListApiHandlers
This commit is contained in:
@@ -6,8 +6,7 @@ import json
|
|||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import needs_scope
|
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
|
@@ -8,8 +8,7 @@ from tornado import web
|
|||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
|
|
||||||
from .._version import __version__
|
from .._version import __version__
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import needs_scope
|
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
|
@@ -5,8 +5,7 @@ import json
|
|||||||
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import needs_scope
|
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
|
@@ -9,8 +9,7 @@ import json
|
|||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import needs_scope
|
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
|
@@ -14,13 +14,12 @@ from tornado import web
|
|||||||
from tornado.iostream import StreamClosedError
|
from tornado.iostream import StreamClosedError
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import roles
|
|
||||||
from ..roles import update_roles
|
from ..roles import update_roles
|
||||||
|
from ..scopes import needs_scope
|
||||||
from ..user import User
|
from ..user import User
|
||||||
from ..utils import isoformat
|
from ..utils import isoformat
|
||||||
from ..utils import iterate_until
|
from ..utils import iterate_until
|
||||||
from ..utils import maybe_future
|
from ..utils import maybe_future
|
||||||
from ..utils import needs_scope
|
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
@@ -17,7 +17,7 @@ from .. import orm
|
|||||||
from ..metrics import SERVER_POLL_DURATION_SECONDS
|
from ..metrics import SERVER_POLL_DURATION_SECONDS
|
||||||
from ..metrics import ServerPollStatus
|
from ..metrics import ServerPollStatus
|
||||||
from ..pagination import Pagination
|
from ..pagination import Pagination
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import maybe_future
|
from ..utils import maybe_future
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
@@ -455,7 +455,7 @@ class AdminHandler(BaseHandler):
|
|||||||
"""Render the admin page."""
|
"""Render the admin page."""
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
@admin_only
|
@needs_scope('admin:users')
|
||||||
async def get(self):
|
async def get(self):
|
||||||
page, per_page, offset = Pagination(config=self.config).get_page_args(self)
|
page, per_page, offset = Pagination(config=self.config).get_page_args(self)
|
||||||
|
|
||||||
|
216
jupyterhub/scopes.py
Normal file
216
jupyterhub/scopes.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import functools
|
||||||
|
import inspect
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from tornado import web
|
||||||
|
from tornado.log import app_log
|
||||||
|
|
||||||
|
from . import orm
|
||||||
|
|
||||||
|
|
||||||
|
class Scope(Enum):
|
||||||
|
ALL = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_scopes(name):
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
users
|
||||||
|
users:name
|
||||||
|
users:groups
|
||||||
|
users:activity
|
||||||
|
users:servers
|
||||||
|
users:tokens
|
||||||
|
|
||||||
|
"""
|
||||||
|
scope_list = [
|
||||||
|
'users',
|
||||||
|
'users:name',
|
||||||
|
'users:groups',
|
||||||
|
'users:activity',
|
||||||
|
'users:servers',
|
||||||
|
'users:tokens',
|
||||||
|
]
|
||||||
|
scope_list.extend(['read:' + scope for scope in scope_list])
|
||||||
|
return {"{}!user={}".format(scope, name) for scope in scope_list}
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_scope_expansion(filter_, filter_value, sub_scope):
|
||||||
|
"""
|
||||||
|
Check if there is a requirements to expand the `group` scope to individual `user` scopes.
|
||||||
|
Assumptions:
|
||||||
|
filter_ != Scope.ALL
|
||||||
|
"""
|
||||||
|
if not (filter_ == 'user' and 'group' in sub_scope):
|
||||||
|
return False
|
||||||
|
if 'user' in sub_scope:
|
||||||
|
return filter_value not in sub_scope['user']
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
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 such user found')
|
||||||
|
group_names = {group.name for group in user.groups}
|
||||||
|
return bool(set(scope_group_names) & group_names)
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_groups(groups):
|
||||||
|
user_set = set()
|
||||||
|
for group in groups:
|
||||||
|
user_set |= {
|
||||||
|
user.name for user in group.users
|
||||||
|
} # todo: I think this could be one query, no for loop
|
||||||
|
return user_set
|
||||||
|
|
||||||
|
|
||||||
|
def get_orm_class(kind):
|
||||||
|
class_dict = {
|
||||||
|
'users': orm.User,
|
||||||
|
'services': orm.Service,
|
||||||
|
'tokens': orm.APIToken,
|
||||||
|
'groups': orm.Group,
|
||||||
|
}
|
||||||
|
if kind not in class_dict:
|
||||||
|
raise ValueError(
|
||||||
|
"Kind must be one of %s, not %s" % (", ".join(class_dict), kind)
|
||||||
|
)
|
||||||
|
return class_dict[kind]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_scope_filter(db, req_scope, sub_scope):
|
||||||
|
# Rough draft
|
||||||
|
scope_translator = {
|
||||||
|
'read:users': 'users',
|
||||||
|
'read:services': 'services',
|
||||||
|
'read:groups': 'groups',
|
||||||
|
}
|
||||||
|
if req_scope not in scope_translator:
|
||||||
|
raise AttributeError("Scope not found; scope filter not constructed")
|
||||||
|
kind = scope_translator[req_scope]
|
||||||
|
Class = get_orm_class(kind)
|
||||||
|
sub_scope_values = next(iter(sub_scope.values()))
|
||||||
|
query = db.query(Class).filter(Class.name.in_(sub_scope_values))
|
||||||
|
scope_filter = [entry.name for entry in query.all()]
|
||||||
|
if 'group' in sub_scope and kind == 'users':
|
||||||
|
groups = db.query(orm.Group).filter(orm.Group.name.in_(sub_scope['group']))
|
||||||
|
scope_filter += _flatten_groups(groups)
|
||||||
|
return set(scope_filter)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_scope(api_handler, req_scope, scopes, **kwargs):
|
||||||
|
# Parse user name and server name together
|
||||||
|
if 'user' in kwargs and 'server' in kwargs:
|
||||||
|
kwargs['server'] = "{}/{}".format(kwargs['user'], kwargs['server'])
|
||||||
|
if req_scope not in scopes:
|
||||||
|
return False
|
||||||
|
if scopes[req_scope] == Scope.ALL:
|
||||||
|
return True
|
||||||
|
# Apply filters
|
||||||
|
sub_scope = scopes[req_scope]
|
||||||
|
if 'scope_filter' in kwargs:
|
||||||
|
scope_filter = _get_scope_filter(api_handler.db, req_scope, sub_scope)
|
||||||
|
return scope_filter
|
||||||
|
else:
|
||||||
|
# Interface change: Now can have multiple filters
|
||||||
|
for (filter_, filter_value) in kwargs.items():
|
||||||
|
if filter_ in sub_scope and filter_value in sub_scope[filter_]:
|
||||||
|
return True
|
||||||
|
if _needs_scope_expansion(filter_, filter_value, sub_scope):
|
||||||
|
group_names = sub_scope['group']
|
||||||
|
if _check_user_in_expanded_scope(
|
||||||
|
api_handler, filter_value, group_names
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scopes(scope_list):
|
||||||
|
"""
|
||||||
|
Parses scopes and filters in something akin to JSON style
|
||||||
|
|
||||||
|
For instance, scope list ["users", "groups!group=foo", "users:servers!server=bar", "users:servers!server=baz"]
|
||||||
|
would lead to scope model
|
||||||
|
{
|
||||||
|
"users":scope.ALL,
|
||||||
|
"users:admin":{
|
||||||
|
"user":[
|
||||||
|
"alice"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users:servers":{
|
||||||
|
"server":[
|
||||||
|
"bar",
|
||||||
|
"baz"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
parsed_scopes = {}
|
||||||
|
for scope in scope_list:
|
||||||
|
base_scope, _, filter_ = scope.partition('!')
|
||||||
|
if not filter_:
|
||||||
|
parsed_scopes[base_scope] = Scope.ALL
|
||||||
|
elif base_scope not in parsed_scopes:
|
||||||
|
parsed_scopes[base_scope] = {}
|
||||||
|
if parsed_scopes[base_scope] != Scope.ALL:
|
||||||
|
key, _, val = filter_.partition('=')
|
||||||
|
if key not in parsed_scopes[base_scope]:
|
||||||
|
parsed_scopes[base_scope][key] = []
|
||||||
|
parsed_scopes[base_scope][key].append(val)
|
||||||
|
return parsed_scopes
|
||||||
|
|
||||||
|
|
||||||
|
def needs_scope(scope):
|
||||||
|
"""Decorator to restrict access to users or services with the required scope"""
|
||||||
|
|
||||||
|
def scope_decorator(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def _auth_func(self, *args, **kwargs):
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
bound_sig = sig.bind(self, *args, **kwargs)
|
||||||
|
bound_sig.apply_defaults()
|
||||||
|
s_kwargs = {}
|
||||||
|
for resource in {'user', 'server', 'group', 'service'}:
|
||||||
|
resource_name = resource + '_name'
|
||||||
|
if resource_name in bound_sig.arguments:
|
||||||
|
resource_value = bound_sig.arguments[resource_name]
|
||||||
|
s_kwargs[resource] = resource_value
|
||||||
|
if 'scope_filter' in bound_sig.arguments:
|
||||||
|
s_kwargs['scope_filter'] = None
|
||||||
|
if 'all' in self.scopes and self.current_user:
|
||||||
|
# todo: What if no user is found? See test_api/test_referer_check
|
||||||
|
self.scopes |= get_user_scopes(self.current_user.name)
|
||||||
|
parsed_scopes = _parse_scopes(self.scopes)
|
||||||
|
scope_filter = _check_scope(self, scope, parsed_scopes, **s_kwargs)
|
||||||
|
if (
|
||||||
|
scope_filter
|
||||||
|
): # Todo: This checks if True or set of resource names. Not very nice yet
|
||||||
|
if isinstance(scope_filter, set):
|
||||||
|
kwargs['scope_filter'] = scope_filter
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
# catching attr error occurring for older_requirements test
|
||||||
|
# could be done more ellegantly?
|
||||||
|
try:
|
||||||
|
request_path = self.request.path
|
||||||
|
except AttributeError:
|
||||||
|
request_path = 'the requested API'
|
||||||
|
app_log.warning(
|
||||||
|
"Not authorizing access to {}. Requires scope {}, not derived from scopes {}".format(
|
||||||
|
request_path, scope, ", ".join(self.scopes)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise web.HTTPError(
|
||||||
|
403,
|
||||||
|
"Action is not authorized with current scopes; requires {}".format(
|
||||||
|
scope
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _auth_func
|
||||||
|
|
||||||
|
return scope_decorator
|
@@ -14,7 +14,6 @@ from pytest import mark
|
|||||||
|
|
||||||
import jupyterhub
|
import jupyterhub
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import roles
|
|
||||||
from ..objects import Server
|
from ..objects import Server
|
||||||
from ..utils import url_path_join as ujoin
|
from ..utils import url_path_join as ujoin
|
||||||
from ..utils import utcnow
|
from ..utils import utcnow
|
||||||
@@ -167,21 +166,27 @@ TIMESTAMP = normalize_timestamp(datetime.now().isoformat() + 'Z')
|
|||||||
|
|
||||||
@mark.user
|
@mark.user
|
||||||
@mark.role
|
@mark.role
|
||||||
async def test_get_users(app):
|
async def test_get_users(app): # todo: Sync with scope tests
|
||||||
db = app.db
|
db = app.db
|
||||||
r = await api_request(app, 'users', headers=auth_header(db, 'admin'))
|
r = await api_request(app, 'users', headers=auth_header(db, 'admin'))
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
users = sorted(r.json(), key=lambda d: d['name'])
|
users = sorted(r.json(), key=lambda d: d['name'])
|
||||||
users = [normalize_user(u) for u in users]
|
users = [normalize_user(u) for u in users]
|
||||||
|
user_model = {
|
||||||
|
'name': 'user',
|
||||||
|
'admin': False,
|
||||||
|
'roles': ['user'],
|
||||||
|
'last_activity': None,
|
||||||
|
}
|
||||||
assert users == [
|
assert users == [
|
||||||
fill_user({'name': 'admin', 'admin': True, 'roles': ['admin']}),
|
fill_user({'name': 'admin', 'admin': True, 'roles': ['admin']}),
|
||||||
fill_user(
|
fill_user(user_model),
|
||||||
{'name': 'user', 'admin': False, 'roles': ['user'], 'last_activity': None}
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
r = await api_request(app, 'users', headers=auth_header(db, 'user'))
|
r = await api_request(app, 'users', headers=auth_header(db, 'user'))
|
||||||
assert r.status_code == 403
|
assert r.status_code == 200
|
||||||
|
r_user_model = json.loads(r.text)[0]
|
||||||
|
assert r_user_model['name'] == user_model['name']
|
||||||
|
|
||||||
|
|
||||||
@mark.user
|
@mark.user
|
||||||
|
@@ -3,17 +3,16 @@ import json
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tornado
|
|
||||||
from pytest import mark
|
from pytest import mark
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from tornado.httputil import HTTPServerRequest
|
from tornado.httputil import HTTPServerRequest
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import roles
|
from .. import roles
|
||||||
from ..utils import check_scope
|
from ..scopes import _check_scope
|
||||||
from ..utils import needs_scope
|
from ..scopes import _parse_scopes
|
||||||
from ..utils import parse_scopes
|
from ..scopes import needs_scope
|
||||||
from ..utils import Scope
|
from ..scopes import Scope
|
||||||
from .utils import add_user
|
from .utils import add_user
|
||||||
from .utils import api_request
|
from .utils import api_request
|
||||||
from .utils import auth_header
|
from .utils import auth_header
|
||||||
@@ -27,7 +26,7 @@ def test_scope_constructor():
|
|||||||
'read:users!user={}'.format(user1),
|
'read:users!user={}'.format(user1),
|
||||||
'read:users!user={}'.format(user2),
|
'read:users!user={}'.format(user2),
|
||||||
]
|
]
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
|
|
||||||
assert 'read:users' in parsed_scopes
|
assert 'read:users' in parsed_scopes
|
||||||
assert parsed_scopes['users']
|
assert parsed_scopes['users']
|
||||||
@@ -36,25 +35,25 @@ def test_scope_constructor():
|
|||||||
|
|
||||||
def test_scope_precendence():
|
def test_scope_precendence():
|
||||||
scope_list = ['read:users!user=maeby', 'read:users']
|
scope_list = ['read:users!user=maeby', 'read:users']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert parsed_scopes['read:users'] == Scope.ALL
|
assert parsed_scopes['read:users'] == Scope.ALL
|
||||||
|
|
||||||
|
|
||||||
def test_scope_check_present():
|
def test_scope_check_present():
|
||||||
handler = None
|
handler = None
|
||||||
scope_list = ['read:users']
|
scope_list = ['read:users']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert check_scope(handler, 'read:users', parsed_scopes)
|
assert _check_scope(handler, 'read:users', parsed_scopes)
|
||||||
assert check_scope(handler, 'read:users', parsed_scopes, user='maeby')
|
assert _check_scope(handler, 'read:users', parsed_scopes, user='maeby')
|
||||||
|
|
||||||
|
|
||||||
def test_scope_check_not_present():
|
def test_scope_check_not_present():
|
||||||
handler = None
|
handler = None
|
||||||
scope_list = ['read:users!user=maeby']
|
scope_list = ['read:users!user=maeby']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert not check_scope(handler, 'read:users', parsed_scopes)
|
assert not _check_scope(handler, 'read:users', parsed_scopes)
|
||||||
assert not check_scope(handler, 'read:users', parsed_scopes, user='gob')
|
assert not _check_scope(handler, 'read:users', parsed_scopes, user='gob')
|
||||||
assert not check_scope(
|
assert not _check_scope(
|
||||||
handler, 'read:users', parsed_scopes, user='gob', server='server'
|
handler, 'read:users', parsed_scopes, user='gob', server='server'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,17 +61,17 @@ def test_scope_check_not_present():
|
|||||||
def test_scope_filters():
|
def test_scope_filters():
|
||||||
handler = None
|
handler = None
|
||||||
scope_list = ['read:users', 'read:users!group=bluths', 'read:users!user=maeby']
|
scope_list = ['read:users', 'read:users!group=bluths', 'read:users!user=maeby']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert check_scope(handler, 'read:users', parsed_scopes, group='bluth')
|
assert _check_scope(handler, 'read:users', parsed_scopes, group='bluth')
|
||||||
assert check_scope(handler, 'read:users', parsed_scopes, user='maeby')
|
assert _check_scope(handler, 'read:users', parsed_scopes, user='maeby')
|
||||||
|
|
||||||
|
|
||||||
def test_scope_multiple_filters():
|
def test_scope_multiple_filters():
|
||||||
handler = None
|
handler = None
|
||||||
assert check_scope(
|
assert _check_scope(
|
||||||
handler,
|
handler,
|
||||||
'read:users',
|
'read:users',
|
||||||
parse_scopes(['read:users!user=george_michael']),
|
_parse_scopes(['read:users!user=george_michael']),
|
||||||
user='george_michael',
|
user='george_michael',
|
||||||
group='bluths',
|
group='bluths',
|
||||||
)
|
)
|
||||||
@@ -81,8 +80,8 @@ def test_scope_multiple_filters():
|
|||||||
def test_scope_parse_server_name():
|
def test_scope_parse_server_name():
|
||||||
handler = None
|
handler = None
|
||||||
scope_list = ['users:servers!server=maeby/server1', 'read:users!user=maeby']
|
scope_list = ['users:servers!server=maeby/server1', 'read:users!user=maeby']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert check_scope(
|
assert _check_scope(
|
||||||
handler, 'users:servers', parsed_scopes, user='maeby', server='server1'
|
handler, 'users:servers', parsed_scopes, user='maeby', server='server1'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -4,7 +4,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import errno
|
import errno
|
||||||
import functools
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
@@ -18,7 +17,6 @@ import warnings
|
|||||||
from binascii import b2a_hex
|
from binascii import b2a_hex
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from enum import Enum
|
|
||||||
from hmac import compare_digest
|
from hmac import compare_digest
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
|
|
||||||
@@ -30,8 +28,6 @@ from tornado.httpclient import HTTPError
|
|||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.platform.asyncio import to_asyncio_future
|
from tornado.platform.asyncio import to_asyncio_future
|
||||||
|
|
||||||
from . import orm # todo: only necessary for scopes, move later
|
|
||||||
|
|
||||||
|
|
||||||
def random_port():
|
def random_port():
|
||||||
"""Get a single random port."""
|
"""Get a single random port."""
|
||||||
@@ -283,8 +279,13 @@ def authenticated_403(self):
|
|||||||
|
|
||||||
@auth_decorator
|
@auth_decorator
|
||||||
def admin_only(self):
|
def admin_only(self):
|
||||||
"""Decorator for restricting access to admin users"""
|
"""Decorator for restricting access to admin users
|
||||||
|
Deprecated in favor of scopes.need_scope()
|
||||||
|
"""
|
||||||
user = self.current_user
|
user = self.current_user
|
||||||
|
app_log.warning(
|
||||||
|
"Admin decorator is deprecated and will be removed soon. Use scope-based decorator instead"
|
||||||
|
)
|
||||||
if user is None or not user.admin:
|
if user is None or not user.admin:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
@@ -297,214 +298,6 @@ def metrics_authentication(self):
|
|||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
|
|
||||||
# Todo: Move all scope-related methods to scope module
|
|
||||||
class Scope(Enum):
|
|
||||||
ALL = True
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_scopes(name):
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
users
|
|
||||||
users:name
|
|
||||||
users:groups
|
|
||||||
users:activity
|
|
||||||
users:servers
|
|
||||||
users:tokens
|
|
||||||
|
|
||||||
"""
|
|
||||||
scope_list = [
|
|
||||||
'users',
|
|
||||||
'users:name',
|
|
||||||
'users:groups',
|
|
||||||
'users:activity',
|
|
||||||
'users:servers',
|
|
||||||
'users:tokens',
|
|
||||||
]
|
|
||||||
scope_list.extend(['read:' + scope for scope in scope_list])
|
|
||||||
return {"{}!user={}".format(scope, name) for scope in scope_list}
|
|
||||||
|
|
||||||
|
|
||||||
def needs_scope_expansion(filter_, filter_value, sub_scope):
|
|
||||||
"""
|
|
||||||
Check if there is a requirements to expand the `group` scope to individual `user` scopes.
|
|
||||||
Assumptions:
|
|
||||||
filter_ != Scope.ALL
|
|
||||||
"""
|
|
||||||
if not (filter_ == 'user' and 'group' in sub_scope):
|
|
||||||
return False
|
|
||||||
if 'user' in sub_scope:
|
|
||||||
return filter_value not in sub_scope['user']
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
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 such user found')
|
|
||||||
group_names = {group.name for group in user.groups}
|
|
||||||
return bool(set(scope_group_names) & group_names)
|
|
||||||
|
|
||||||
|
|
||||||
def _flatten_groups(groups):
|
|
||||||
user_set = set()
|
|
||||||
for group in groups:
|
|
||||||
user_set |= {
|
|
||||||
user.name for user in group.users
|
|
||||||
} # todo: I think this could be one query, no for loop
|
|
||||||
return user_set
|
|
||||||
|
|
||||||
|
|
||||||
def get_orm_class(kind):
|
|
||||||
class_dict = {
|
|
||||||
'users': orm.User,
|
|
||||||
'services': orm.Service,
|
|
||||||
'tokens': orm.APIToken,
|
|
||||||
'groups': orm.Group,
|
|
||||||
}
|
|
||||||
if kind not in class_dict:
|
|
||||||
raise ValueError(
|
|
||||||
"Kind must be one of %s, not %s" % (", ".join(class_dict), kind)
|
|
||||||
)
|
|
||||||
return class_dict[kind]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_scope_filter(db, req_scope, sub_scope):
|
|
||||||
# Rough draft
|
|
||||||
scope_translator = {
|
|
||||||
'read:users': 'users',
|
|
||||||
'read:services': 'services',
|
|
||||||
'read:groups': 'groups',
|
|
||||||
}
|
|
||||||
if req_scope not in scope_translator:
|
|
||||||
raise AttributeError("Scope not found; scope filter not constructed")
|
|
||||||
kind = scope_translator[req_scope]
|
|
||||||
Class = get_orm_class(kind)
|
|
||||||
sub_scope_values = next(iter(sub_scope.values()))
|
|
||||||
query = db.query(Class).filter(Class.name.in_(sub_scope_values))
|
|
||||||
scope_filter = [entry.name for entry in query.all()]
|
|
||||||
if 'group' in sub_scope and kind == 'users':
|
|
||||||
groups = db.query(orm.Group).filter(orm.Group.name.in_(sub_scope['group']))
|
|
||||||
scope_filter += _flatten_groups(groups)
|
|
||||||
return set(scope_filter)
|
|
||||||
|
|
||||||
|
|
||||||
def check_scope(api_handler, req_scope, scopes, **kwargs):
|
|
||||||
# Parse user name and server name together
|
|
||||||
if 'user' in kwargs and 'server' in kwargs:
|
|
||||||
kwargs['server'] = "{}/{}".format(kwargs['user'], kwargs['server'])
|
|
||||||
if req_scope not in scopes:
|
|
||||||
return False
|
|
||||||
if scopes[req_scope] == Scope.ALL:
|
|
||||||
return True
|
|
||||||
# Apply filters
|
|
||||||
sub_scope = scopes[req_scope]
|
|
||||||
if 'scope_filter' in kwargs:
|
|
||||||
scope_filter = _get_scope_filter(api_handler.db, req_scope, sub_scope)
|
|
||||||
return scope_filter
|
|
||||||
else:
|
|
||||||
# Interface change: Now can have multiple filters
|
|
||||||
for (filter_, filter_value) in kwargs.items():
|
|
||||||
if filter_ in sub_scope and filter_value in sub_scope[filter_]:
|
|
||||||
return True
|
|
||||||
if needs_scope_expansion(filter_, filter_value, sub_scope):
|
|
||||||
group_names = sub_scope['group']
|
|
||||||
if check_user_in_expanded_scope(api_handler, filter_value, group_names):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
# Todo: make some methods private
|
|
||||||
|
|
||||||
|
|
||||||
def parse_scopes(scope_list):
|
|
||||||
"""
|
|
||||||
Parses scopes and filters in something akin to JSON style
|
|
||||||
|
|
||||||
For instance, scope list ["users", "groups!group=foo", "users:servers!server=bar", "users:servers!server=baz"]
|
|
||||||
would lead to scope model
|
|
||||||
{
|
|
||||||
"users":scope.ALL,
|
|
||||||
"users:admin":{
|
|
||||||
"user":[
|
|
||||||
"alice"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"users:servers":{
|
|
||||||
"server":[
|
|
||||||
"bar",
|
|
||||||
"baz"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
parsed_scopes = {}
|
|
||||||
for scope in scope_list:
|
|
||||||
base_scope, _, filter_ = scope.partition('!')
|
|
||||||
if not filter_:
|
|
||||||
parsed_scopes[base_scope] = Scope.ALL
|
|
||||||
elif base_scope not in parsed_scopes:
|
|
||||||
parsed_scopes[base_scope] = {}
|
|
||||||
if parsed_scopes[base_scope] != Scope.ALL:
|
|
||||||
key, _, val = filter_.partition('=')
|
|
||||||
if key not in parsed_scopes[base_scope]:
|
|
||||||
parsed_scopes[base_scope][key] = []
|
|
||||||
parsed_scopes[base_scope][key].append(val)
|
|
||||||
return parsed_scopes
|
|
||||||
|
|
||||||
|
|
||||||
def needs_scope(scope):
|
|
||||||
"""Decorator to restrict access to users or services with the required scope"""
|
|
||||||
|
|
||||||
def scope_decorator(func):
|
|
||||||
@functools.wraps(func)
|
|
||||||
def _auth_func(self, *args, **kwargs):
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
bound_sig = sig.bind(self, *args, **kwargs)
|
|
||||||
bound_sig.apply_defaults()
|
|
||||||
s_kwargs = {}
|
|
||||||
for resource in {'user', 'server', 'group', 'service'}:
|
|
||||||
resource_name = resource + '_name'
|
|
||||||
if resource_name in bound_sig.arguments:
|
|
||||||
resource_value = bound_sig.arguments[resource_name]
|
|
||||||
s_kwargs[resource] = resource_value
|
|
||||||
if 'scope_filter' in bound_sig.arguments:
|
|
||||||
s_kwargs['scope_filter'] = None
|
|
||||||
if 'all' in self.scopes and self.current_user:
|
|
||||||
# todo: What if no user is found? See test_api/test_referer_check
|
|
||||||
self.scopes |= get_user_scopes(self.current_user.name)
|
|
||||||
parsed_scopes = parse_scopes(self.scopes)
|
|
||||||
scope_filter = check_scope(self, scope, parsed_scopes, **s_kwargs)
|
|
||||||
if scope_filter:
|
|
||||||
if isinstance(scope_filter, set):
|
|
||||||
kwargs['scope_filter'] = scope_filter
|
|
||||||
return func(self, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
# catching attr error occurring for older_requirements test
|
|
||||||
# could be done more ellegantly?
|
|
||||||
try:
|
|
||||||
request_path = self.request.path
|
|
||||||
except AttributeError:
|
|
||||||
request_path = 'the requested API'
|
|
||||||
app_log.warning(
|
|
||||||
"Not authorizing access to {}. Requires scope {}, not derived from scopes {}".format(
|
|
||||||
request_path, scope, ", ".join(self.scopes)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise web.HTTPError(
|
|
||||||
403,
|
|
||||||
"Action is not authorized with current scopes; requires {}".format(
|
|
||||||
scope
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _auth_func
|
|
||||||
|
|
||||||
return scope_decorator
|
|
||||||
|
|
||||||
|
|
||||||
# Token utilities
|
# Token utilities
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user