Merge remote-tracking branch 'upstream/rbac' into merge_api_with_orm

This commit is contained in:
0mar
2020-12-09 15:24:48 +01:00
18 changed files with 927 additions and 20 deletions

View File

@@ -150,10 +150,13 @@ class APIHandler(BaseHandler):
expires_at = None
if isinstance(token, orm.APIToken):
kind = 'api_token'
roles = [r.name for r in token.roles]
extra = {'note': token.note}
expires_at = token.expires_at
elif isinstance(token, orm.OAuthAccessToken):
kind = 'oauth'
# oauth tokens do not bear roles
roles = []
extra = {'oauth_client': token.client.description or token.client.client_id}
if token.expires_at:
expires_at = datetime.fromtimestamp(token.expires_at)
@@ -174,6 +177,7 @@ class APIHandler(BaseHandler):
owner_key: owner,
'id': token.api_id,
'kind': kind,
'roles': [role for role in roles],
'created': isoformat(token.created),
'last_activity': isoformat(token.last_activity),
'expires_at': isoformat(expires_at),
@@ -190,6 +194,7 @@ class APIHandler(BaseHandler):
'kind': 'user',
'name': user.name,
'admin': user.admin,
'roles': [r.name for r in user.roles],
'groups': [g.name for g in user.groups],
'server': user.url if user.running else None,
'pending': None,
@@ -221,7 +226,12 @@ class APIHandler(BaseHandler):
def service_model(self, service):
"""Get the JSON model for a Service object"""
return {'kind': 'service', 'name': service.name, 'admin': service.admin}
return {
'kind': 'service',
'name': service.name,
'admin': service.admin,
'roles': [r.name for r in service.roles],
}
_user_model_types = {'name': str, 'admin': bool, 'groups': list, 'auth_state': dict}

View File

@@ -19,6 +19,7 @@ def service_model(service):
return {
'name': service.name,
'admin': service.admin,
'roles': [r.name for r in service.roles],
'url': service.url,
'prefix': service.server.base_url if service.server else '',
'command': service.command,

View File

@@ -14,6 +14,8 @@ from tornado import web
from tornado.iostream import StreamClosedError
from .. import orm
from .. import roles
from ..roles import update_roles
from ..user import User
from ..utils import isoformat
from ..utils import iterate_until
@@ -140,7 +142,8 @@ class UserListAPIHandler(APIHandler):
user = self.user_from_username(name)
if admin:
user.admin = True
self.db.commit()
update_roles(self.db, obj=user, kind='users')
self.db.commit()
try:
await maybe_future(self.authenticator.add_user(user))
except Exception as e:
@@ -202,7 +205,8 @@ class UserAPIHandler(APIHandler):
self._check_user_model(data)
if 'admin' in data:
user.admin = data['admin']
self.db.commit()
update_roles(self.db, obj=user, kind='users')
self.db.commit()
try:
await maybe_future(self.authenticator.add_user(user))
@@ -261,6 +265,8 @@ class UserAPIHandler(APIHandler):
await user.save_auth_state(value)
else:
setattr(user, key, value)
if key == 'admin':
update_roles(self.db, obj=user, kind='users')
self.db.commit()
user_ = self.user_model(user)
user_['auth_state'] = await user.get_auth_state()
@@ -345,9 +351,19 @@ class UserTokenListAPIHandler(APIHandler):
if requester is not user:
note += " by %s %s" % (kind, requester.name)
api_token = user.new_api_token(
note=note, expires_in=body.get('expires_in', None)
)
token_roles = body.get('roles')
try:
api_token = user.new_api_token(
note=note, expires_in=body.get('expires_in', None), roles=token_roles
)
except NameError:
raise web.HTTPError(404, "Requested roles %r not found" % token_roles)
except ValueError:
raise web.HTTPError(
403,
"Requested token roles %r have higher permissions than the token owner"
% token_roles,
)
if requester is not user:
self.log.info(
"%s %s requested API token for %s",

View File

@@ -73,6 +73,7 @@ from .services.service import Service
from . import crypto
from . import dbutil, orm
from . import roles
from .user import UserDict
from .oauth.provider import make_provider
from ._data import DATA_FILES_PATH
@@ -312,6 +313,31 @@ class JupyterHub(Application):
""",
).tag(config=True)
load_roles = List(
Dict(),
help="""List of predefined role dictionaries to load at startup.
For instance::
load_roles = [
{
'name': 'teacher',
'description': 'Access users information, servers and groups without create/delete privileges',
'scopes': ['users', 'groups'],
'users': ['cyclops', 'gandalf'],
'services': [],
'tokens': []
}
]
All keys apart from 'name' are optional.
See all the available scopes in the JupyterHub REST API documentation.
Default roles are defined in roles.py.
""",
).tag(config=True)
config_file = Unicode('jupyterhub_config.py', help="The config file to load").tag(
config=True
)
@@ -1700,7 +1726,6 @@ class JupyterHub(Application):
db.add(user)
else:
user.admin = True
# the admin_users config variable will never be used after this point.
# only the database values will be referenced.
@@ -1799,6 +1824,46 @@ class JupyterHub(Application):
group.users.append(user)
db.commit()
async def init_roles(self):
"""Load default and predefined roles into the database"""
db = self.db
role_bearers = ['users', 'services', 'tokens']
# load default roles
default_roles = roles.get_default_roles()
for role in default_roles:
roles.add_role(db, role)
# load predefined roles from config file
for predef_role in self.load_roles:
roles.add_role(db, predef_role)
# add users, services and/or tokens
for bearer in role_bearers:
if bearer in predef_role.keys():
for bname in predef_role[bearer]:
if bearer == 'users':
bname = self.authenticator.normalize_username(bname)
if not (
await maybe_future(
self.authenticator.check_allowed(bname, None)
)
):
raise ValueError(
"Username %r is not in Authenticator.allowed_users"
% bname
)
roles.add_obj(
db, objname=bname, kind=bearer, rolename=predef_role['name']
)
# make sure all users, services and tokens have at least one role (update with default)
for bearer in role_bearers:
Class = roles.get_orm_class(bearer)
for obj in db.query(Class):
if len(obj.roles) < 1:
roles.update_roles(db, obj=obj, kind=bearer)
db.commit()
async def _add_tokens(self, token_dict, kind):
"""Add tokens for users or services to the database"""
if kind == 'user':
@@ -1910,6 +1975,7 @@ class JupyterHub(Application):
base_url=self.base_url,
db=self.db,
orm=orm_service,
roles=orm_service.roles,
domain=domain,
host=host,
hub=self.hub,
@@ -2388,6 +2454,7 @@ class JupyterHub(Application):
await self.init_groups()
self.init_services()
await self.init_api_tokens()
await self.init_roles()
self.init_tornado_settings()
self.init_handlers()
self.init_tornado_application()

View File

@@ -139,7 +139,7 @@ def upgrade_if_needed(db_url, backup=True, log=None):
def shell(args=None):
"""Start an IPython shell hooked up to the jupyerhub database"""
"""Start an IPython shell hooked up to the jupyterhub database"""
from .app import JupyterHub
hub = JupyterHub()

View File

@@ -30,6 +30,7 @@ from tornado.web import RequestHandler
from .. import __version__
from .. import orm
from .. import roles
from ..metrics import PROXY_ADD_DURATION_SECONDS
from ..metrics import PROXY_DELETE_DURATION_SECONDS
from ..metrics import ProxyDeleteStatus
@@ -457,6 +458,7 @@ class BaseHandler(RequestHandler):
# not found, create and register user
u = orm.User(name=username)
self.db.add(u)
roles.update_roles(self.db, obj=u, kind='users')
TOTAL_USERS.inc()
self.db.commit()
user = self._user_from_orm(u)
@@ -736,6 +738,7 @@ class BaseHandler(RequestHandler):
# Only set `admin` if the authenticator returned an explicit value.
if admin is not None and admin != user.admin:
user.admin = admin
roles.update_roles(self.db, obj=user, kind='users')
self.db.commit()
# always set auth_state and commit,
# because there could be key-rotation or clearing of previous values

View File

@@ -39,6 +39,9 @@ from sqlalchemy.types import Text
from sqlalchemy.types import TypeDecorator
from tornado.log import app_log
from .roles import add_role
from .roles import get_default_roles
from .roles import update_roles
from .utils import compare_token
from .utils import hash_token
from .utils import new_token
@@ -90,6 +93,26 @@ class JSONDict(TypeDecorator):
return value
class JSONList(JSONDict):
"""Represents an immutable structure as a json-encoded string (to be used for list type columns).
Usage::
JSONList(JSONDict)
"""
def process_bind_param(self, value, dialect):
if isinstance(value, list) and value is not None:
value = json.dumps(value)
return value
def process_result_value(self, value, dialect):
if value is not None:
value = json.loads(value)
return value
Base = declarative_base()
Base.log = app_log
@@ -113,6 +136,65 @@ class Server(Base):
return "<Server(%s:%s)>" % (self.ip, self.port)
# user:role many:many mapping table
user_role_map = Table(
'user_role_map',
Base.metadata,
Column('user_id', ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
)
# service:role many:many mapping table
service_role_map = Table(
'service_role_map',
Base.metadata,
Column(
'service_id', ForeignKey('services.id', ondelete='CASCADE'), primary_key=True
),
Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
)
# token:role many:many mapping table
api_token_role_map = Table(
'api_token_role_map',
Base.metadata,
Column(
'api_token_id',
ForeignKey('api_tokens.id', ondelete='CASCADE'),
primary_key=True,
),
Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True),
)
class Role(Base):
"""User Roles"""
__tablename__ = 'roles'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(Unicode(255), unique=True)
description = Column(Unicode(1023))
scopes = Column(JSONList)
users = relationship('User', secondary='user_role_map', backref='roles')
services = relationship('Service', secondary='service_role_map', backref='roles')
tokens = relationship('APIToken', secondary='api_token_role_map', backref='roles')
def __repr__(self):
return "<%s %s (%s) - scopes: %s>" % (
self.__class__.__name__,
self.name,
self.description,
self.scopes,
)
@classmethod
def find(cls, db, name):
"""Find a role by name.
Returns None if not found.
"""
return db.query(cls).filter(cls.name == name).first()
# user:group many:many mapping table
user_group_map = Table(
'user_group_map',
@@ -504,6 +586,7 @@ class APIToken(Hashed, Base):
token=None,
user=None,
service=None,
roles=None,
note='',
generated=True,
expires_in=None,
@@ -532,6 +615,14 @@ class APIToken(Hashed, Base):
if expires_in is not None:
orm_token.expires_at = cls.now() + timedelta(seconds=expires_in)
db.add(orm_token)
# load default roles if they haven't been initiated
# correct to have this here? otherwise some tests fail
user_role = Role.find(db, 'user')
if not user_role:
default_roles = get_default_roles()
for role in default_roles:
add_role(db, role)
update_roles(db, obj=orm_token, kind='tokens', roles=roles)
db.commit()
return token

261
jupyterhub/roles.py Normal file
View File

@@ -0,0 +1,261 @@
"""Roles utils"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
from itertools import chain
from . import orm
def get_default_roles():
"""Returns a list of default role dictionaries"""
default_roles = [
{
'name': 'user',
'description': 'Everything the user can do',
'scopes': ['all'],
},
{
'name': 'admin',
'description': 'Admin privileges (currently can do everything)',
'scopes': [
'all',
'users',
'users:tokens',
'admin:users',
'admin:users:servers',
'groups',
'admin:groups',
'read:services',
'read:hub',
'proxy',
'shutdown',
],
},
{
'name': 'server',
'description': 'Post activity only',
'scopes': ['users:activity!user=username'],
},
]
return default_roles
def get_scopes():
"""
Returns a dictionary of scopes:
scopes.keys() = scopes of highest level and scopes that have their own subscopes
scopes.values() = a list of first level subscopes or None
"""
scopes = {
'all': ['read:all'],
'users': ['read:users', 'users:activity', 'users:servers'],
'read:users': [
'read:users:name',
'read:users:groups',
'read:users:activity',
'read:users:servers',
],
'users:tokens': ['read:users:tokens'],
'admin:users': None,
'admin:users:servers': None,
'groups': ['read:groups'],
'admin:groups': None,
'read:services': None,
'read:hub': None,
'proxy': None,
'shutdown': None,
}
return scopes
def expand_scope(scopename):
"""Returns a set of all subscopes"""
scopes = get_scopes()
subscopes = [scopename]
def expand_subscopes(index):
more_subscopes = list(
filter(lambda scope: scope in scopes.keys(), subscopes[index:])
)
for scope in more_subscopes:
subscopes.extend(scopes[scope])
if scopename in scopes.keys() and scopes[scopename] is not None:
subscopes.extend(scopes[scopename])
# record the index from where it should check for "subscopes of sub-subscopes"
index_for_sssc = len(subscopes)
# check for "subscopes of subscopes"
expand_subscopes(index=1)
# check for "subscopes of sub-subscopes"
expand_subscopes(index=index_for_sssc)
expanded_scope = set(subscopes)
return expanded_scope
def get_subscopes(*args):
"""Returns a set of all available subscopes for a specified role or list of roles"""
scope_list = []
for role in args:
scope_list.extend(role.scopes)
scopes = set(chain.from_iterable(list(map(expand_scope, scope_list))))
return scopes
def add_role(db, role_dict):
"""Adds a new role to database or modifies an existing one"""
if 'name' not in role_dict.keys():
raise ValueError('Role must have a name')
else:
name = role_dict['name']
role = orm.Role.find(db, name)
description = role_dict.get('description')
scopes = role_dict.get('scopes')
if role is None:
role = orm.Role(name=name, description=description, scopes=scopes)
db.add(role)
else:
if description:
role.description = description
if scopes:
role.scopes = scopes
db.commit()
def get_orm_class(kind):
if kind == 'users':
Class = orm.User
elif kind == 'services':
Class = orm.Service
elif kind == 'tokens':
Class = orm.APIToken
else:
raise ValueError("kind must be users, services or tokens, not %r" % kind)
return Class
def existing_only(func):
"""Decorator for checking if objects and roles exist"""
def check_existence(db, objname, kind, rolename):
Class = get_orm_class(kind)
obj = Class.find(db, objname)
role = orm.Role.find(db, rolename)
if obj is None:
raise ValueError("%r of kind %r does not exist" % (objname, kind))
elif role is None:
raise ValueError("Role %r does not exist" % rolename)
else:
func(db, obj, kind, role)
return check_existence
@existing_only
def add_obj(db, objname, kind, rolename):
"""Adds a role for users, services or tokens"""
if rolename not in objname.roles:
objname.roles.append(rolename)
db.commit()
@existing_only
def remove_obj(db, objname, kind, rolename):
"""Removes a role for users, services or tokens"""
if rolename in objname.roles:
objname.roles.remove(rolename)
db.commit()
def switch_default_role(db, obj, kind, admin):
"""Switch between default user and admin roles for users/services"""
user_role = orm.Role.find(db, 'user')
admin_role = orm.Role.find(db, 'admin')
def add_and_remove(db, obj, kind, current_role, new_role):
if current_role in obj.roles:
remove_obj(db, objname=obj.name, kind=kind, rolename=current_role.name)
# only add new default role if the user has no other roles
if len(obj.roles) < 1:
add_obj(db, objname=obj.name, kind=kind, rolename=new_role.name)
if admin:
add_and_remove(db, obj, kind, user_role, admin_role)
else:
add_and_remove(db, obj, kind, admin_role, user_role)
def update_roles(db, obj, kind, roles=None):
"""Updates object's roles if specified,
assigns default if no roles specified"""
Class = get_orm_class(kind)
user_role = orm.Role.find(db, 'user')
if roles:
for rolename in roles:
if Class == orm.APIToken:
role = orm.Role.find(db, rolename)
if role:
# compare the requested role permissions with the owner's permissions (scopes)
token_scopes = get_subscopes(role)
# find the owner and their roles
owner = None
if obj.user_id:
owner = db.query(orm.User).get(obj.user_id)
elif obj.service_id:
owner = db.query(orm.Service).get(obj.service_id)
if owner:
owner_scopes = get_subscopes(*owner.roles)
if token_scopes.issubset(owner_scopes):
role.tokens.append(obj)
else:
raise ValueError(
'Requested token role %r has higher permissions than the token owner'
% rolename
)
else:
raise NameError('Role %r does not exist' % rolename)
else:
add_obj(db, objname=obj.name, kind=kind, rolename=rolename)
else:
# tokens can have only 'user' role as default
# assign the default only for user tokens
if Class == orm.APIToken:
if len(obj.roles) < 1 and obj.user is not None:
user_role.tokens.append(obj)
db.commit()
# users and services can have 'user' or 'admin' roles as default
else:
switch_default_role(db, obj, kind, obj.admin)

View File

@@ -267,6 +267,7 @@ class Service(LoggingConfigurable):
base_url = Unicode()
db = Any()
orm = Any()
roles = Any()
cookie_options = Dict()
oauth_provider = Any()

View File

@@ -43,6 +43,7 @@ from traitlets import Dict
from .. import metrics
from .. import orm
from .. import roles
from ..app import JupyterHub
from ..auth import PAMAuthenticator
from ..objects import Server
@@ -314,13 +315,15 @@ class MockHub(JupyterHub):
test_clean_db = Bool(True)
def init_db(self):
"""Ensure we start with a clean user list"""
"""Ensure we start with a clean user & role list"""
super().init_db()
if self.test_clean_db:
for user in self.db.query(orm.User):
self.db.delete(user)
for group in self.db.query(orm.Group):
self.db.delete(group)
for role in self.db.query(orm.Role):
self.db.delete(role)
self.db.commit()
async def initialize(self, argv=None):
@@ -338,6 +341,8 @@ class MockHub(JupyterHub):
self.db.add(user)
self.db.commit()
metrics.TOTAL_USERS.inc()
roles.update_roles(self.db, obj=user, kind='users')
self.db.commit()
def stop(self):
super().stop()

View File

@@ -14,6 +14,7 @@ from pytest import mark
import jupyterhub
from .. import orm
from .. import roles
from ..objects import Server
from ..utils import url_path_join as ujoin
from ..utils import utcnow
@@ -64,6 +65,7 @@ async def test_auth_api(app):
async def test_referer_check(app):
url = ujoin(public_host(app), app.hub.base_url)
host = urlparse(url).netloc
# add admin user
user = find_user(app.db, 'admin')
if user is None:
user = add_user(app.db, name='admin', admin=True)
@@ -150,6 +152,7 @@ def fill_user(model):
"""
model.setdefault('server', None)
model.setdefault('kind', 'user')
model.setdefault('roles', [])
model.setdefault('groups', [])
model.setdefault('admin', False)
model.setdefault('server', None)
@@ -164,6 +167,7 @@ TIMESTAMP = normalize_timestamp(datetime.now().isoformat() + 'Z')
@mark.user
@mark.role
async def test_get_users(app):
db = app.db
r = await api_request(app, 'users', headers=auth_header(db, 'admin'))
@@ -172,8 +176,10 @@ async def test_get_users(app):
users = sorted(r.json(), key=lambda d: d['name'])
users = [normalize_user(u) for u in users]
assert users == [
fill_user({'name': 'admin', 'admin': True}),
fill_user({'name': 'user', 'admin': False, 'last_activity': None}),
fill_user({'name': 'admin', 'admin': True, 'roles': ['admin']}),
fill_user(
{'name': 'user', 'admin': False, 'roles': ['user'], 'last_activity': None}
),
]
with mock_role(app, 'user'):
r = await api_request(app, 'users', headers=auth_header(db, 'user'))
@@ -288,6 +294,7 @@ async def test_get_self(app):
@mark.user
@mark.role
async def test_add_user(app):
db = app.db
name = 'newuser'
@@ -297,9 +304,13 @@ async def test_add_user(app):
assert user is not None
assert user.name == name
assert not user.admin
# assert newuser has default 'user' role
assert orm.Role.find(db, 'user') in user.roles
assert orm.Role.find(db, 'admin') not in user.roles
@mark.user
@mark.role
async def test_get_user(app):
name = 'user'
_ = await api_request(app, 'users', name, headers=auth_header(app.db, name))
@@ -312,7 +323,7 @@ async def test_get_user(app):
assert r.status_code == 200
user = normalize_user(r.json())
assert user == fill_user({'name': name, 'auth_state': None})
assert user == fill_user({'name': name, 'roles': ['user'], 'auth_state': None})
@mark.user
@@ -340,6 +351,7 @@ async def test_add_multi_user_invalid(app):
@mark.user
@mark.role
async def test_add_multi_user(app):
db = app.db
names = ['a', 'b']
@@ -356,6 +368,9 @@ async def test_add_multi_user(app):
assert user is not None
assert user.name == name
assert not user.admin
# assert default 'user' role added
assert orm.Role.find(db, 'user') in user.roles
assert orm.Role.find(db, 'admin') not in user.roles
# try to create the same users again
r = await api_request(
@@ -376,6 +391,7 @@ async def test_add_multi_user(app):
@mark.user
@mark.role
async def test_add_multi_user_admin(app):
db = app.db
names = ['c', 'd']
@@ -395,6 +411,8 @@ async def test_add_multi_user_admin(app):
assert user is not None
assert user.name == name
assert user.admin
assert orm.Role.find(db, 'user') not in user.roles
assert orm.Role.find(db, 'admin') in user.roles
@mark.user
@@ -420,6 +438,7 @@ async def test_add_user_duplicate(app):
@mark.user
@mark.role
async def test_add_admin(app):
db = app.db
name = 'newadmin'
@@ -431,6 +450,9 @@ async def test_add_admin(app):
assert user is not None
assert user.name == name
assert user.admin
# assert newadmin has default 'admin' role
assert orm.Role.find(db, 'user') not in user.roles
assert orm.Role.find(db, 'admin') in user.roles
@mark.user
@@ -442,6 +464,7 @@ async def test_delete_user(app):
@mark.user
@mark.role
async def test_make_admin(app):
db = app.db
name = 'admin2'
@@ -451,15 +474,20 @@ async def test_make_admin(app):
assert user is not None
assert user.name == name
assert not user.admin
assert orm.Role.find(db, 'user') in user.roles
assert orm.Role.find(db, 'admin') not in user.roles
r = await api_request(
app, 'users', name, method='patch', data=json.dumps({'admin': True})
)
assert r.status_code == 200
user = find_user(db, name)
assert user is not None
assert user.name == name
assert user.admin
assert orm.Role.find(db, 'user') not in user.roles
assert orm.Role.find(db, 'admin') in user.roles
@mark.user
@@ -1184,7 +1212,7 @@ async def test_token_as_user_deprecated(app, as_user, for_user, status):
# ensure both users exist
u = add_user(app.db, app, name=as_user)
if for_user != 'missing':
add_user(app.db, app, name=for_user)
for_user_obj = add_user(app.db, app, name=for_user)
data = {'username': for_user}
headers = {'Authorization': 'token %s' % u.new_api_token()}
r = await api_request(
@@ -1277,7 +1305,7 @@ async def test_token_for_user(app, as_user, for_user, status):
# ensure both users exist
u = add_user(app.db, app, name=as_user)
if for_user != 'missing':
add_user(app.db, app, name=for_user)
for_user_obj = add_user(app.db, app, name=for_user)
data = {'username': for_user}
headers = {'Authorization': 'token %s' % u.new_api_token()}
r = await api_request(
@@ -1294,6 +1322,7 @@ async def test_token_for_user(app, as_user, for_user, status):
if status != 200:
return
assert 'token' in reply
token_id = reply['id']
r = await api_request(app, 'users', for_user, 'tokens', token_id, headers=headers)
r.raise_for_status()
@@ -1565,6 +1594,7 @@ async def test_get_services(app, mockservice_url):
mockservice.name: {
'name': mockservice.name,
'admin': True,
'roles': [],
'command': mockservice.command,
'pid': mockservice.proc.pid,
'prefix': mockservice.server.base_url,
@@ -1590,6 +1620,7 @@ async def test_get_service(app, mockservice_url):
assert service == {
'name': mockservice.name,
'admin': True,
'roles': [],
'command': mockservice.command,
'pid': mockservice.proc.pid,
'prefix': mockservice.server.base_url,

View File

@@ -56,6 +56,7 @@ async def test_default_server(app, named_servers):
assert user_model == fill_user(
{
'name': username,
'roles': ['user'],
'auth_state': None,
'server': user.url,
'servers': {
@@ -86,7 +87,7 @@ async def test_default_server(app, named_servers):
user_model = normalize_user(r.json())
assert user_model == fill_user(
{'name': username, 'servers': {}, 'auth_state': None}
{'name': username, 'roles': ['user'], 'servers': {}, 'auth_state': None}
)
@@ -117,6 +118,7 @@ async def test_create_named_server(app, named_servers):
assert user_model == fill_user(
{
'name': username,
'roles': ['user'],
'auth_state': None,
'servers': {
servername: {
@@ -159,7 +161,7 @@ async def test_delete_named_server(app, named_servers):
user_model = normalize_user(r.json())
assert user_model == fill_user(
{'name': username, 'auth_state': None, 'servers': {}}
{'name': username, 'roles': ['user'], 'auth_state': None, 'servers': {}}
)
# wrapper Spawner is gone
assert servername not in user.spawners

View File

@@ -244,10 +244,12 @@ def test_groups(db):
db.commit()
assert group.users == []
assert user.groups == []
group.users.append(user)
db.commit()
assert group.users == [user]
assert user.groups == [group]
db.delete(user)
db.commit()
assert group.users == []
@@ -459,7 +461,7 @@ def test_group_delete_cascade(db):
assert group2 in user2.groups
# now start deleting
# 1. remove group via user.groups
# 1. remove group via user.group
user1.groups.remove(group2)
db.commit()
assert user1 not in group2.users
@@ -479,6 +481,7 @@ def test_group_delete_cascade(db):
# 4. delete user object
db.delete(user1)
db.delete(user2)
db.commit()
assert user1 not in group1.users

View File

@@ -0,0 +1,406 @@
"""Test roles"""
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
import json
from pytest import mark
from .. import orm
from .. import roles
from ..utils import maybe_future
from .mocking import MockHub
from .utils import add_user
from .utils import api_request
@mark.role
def test_orm_roles(db):
"""Test orm roles setup"""
user_role = orm.Role.find(db, name='user')
if not user_role:
user_role = orm.Role(name='user', scopes=['all', 'read:all'])
db.add(user_role)
db.commit()
service_role = orm.Role(name='service', scopes=['users:servers'])
db.add(service_role)
db.commit()
user = orm.User(name='falafel')
db.add(user)
db.commit()
service = orm.Service(name='kebab')
db.add(service)
db.commit()
assert user_role.users == []
assert user_role.services == []
assert service_role.users == []
assert service_role.services == []
assert user.roles == []
assert service.roles == []
user_role.users.append(user)
service_role.services.append(service)
db.commit()
assert user_role.users == [user]
assert user.roles == [user_role]
assert service_role.services == [service]
assert service.roles == [service_role]
# check token creation without specifying its role
# assigns it the default 'user' role
token = user.new_api_token()
user_token = orm.APIToken.find(db, token=token)
assert user_token in user_role.tokens
assert user_role in user_token.roles
# check creating token with a specific role
token = service.new_api_token(roles=['service'])
service_token = orm.APIToken.find(db, token=token)
assert service_token in service_role.tokens
assert service_role in service_token.roles
# check deleting user removes the user and the token from roles
db.delete(user)
db.commit()
assert user_role.users == []
assert user_token not in user_role.tokens
# check deleting the service token removes it from 'service' role
db.delete(service_token)
db.commit()
assert service_token not in service_role.tokens
# check deleting the 'service' role removes it from service roles
db.delete(service_role)
db.commit()
assert service.roles == []
db.delete(service)
db.commit()
@mark.role
def test_orm_roles_delete_cascade(db):
"""Orm roles cascade"""
user1 = orm.User(name='user1')
user2 = orm.User(name='user2')
role1 = orm.Role(name='role1')
role2 = orm.Role(name='role2')
db.add(user1)
db.add(user2)
db.add(role1)
db.add(role2)
db.commit()
# add user to role via user.roles
user1.roles.append(role1)
db.commit()
assert user1 in role1.users
assert role1 in user1.roles
# add user to role via roles.users
role1.users.append(user2)
db.commit()
assert user2 in role1.users
assert role1 in user2.roles
# fill role2 and check role1 again
role2.users.append(user1)
role2.users.append(user2)
db.commit()
assert user1 in role1.users
assert user2 in role1.users
assert user1 in role2.users
assert user2 in role2.users
assert role1 in user1.roles
assert role1 in user2.roles
assert role2 in user1.roles
assert role2 in user2.roles
# now start deleting
# 1. remove role via user.roles
user1.roles.remove(role2)
db.commit()
assert user1 not in role2.users
assert role2 not in user1.roles
# 2. remove user via role.users
role1.users.remove(user2)
db.commit()
assert user2 not in role1.users
assert role1 not in user2.roles
# 3. delete role object
db.delete(role2)
db.commit()
assert role2 not in user1.roles
assert role2 not in user2.roles
# 4. delete user object
db.delete(user1)
db.delete(user2)
db.commit()
assert user1 not in role1.users
@mark.role
@mark.parametrize(
"scopes, subscopes",
[
(
['users'],
{
'users',
'read:users',
'users:activity',
'users:servers',
'read:users:name',
'read:users:groups',
'read:users:activity',
'read:users:servers',
},
),
(
['read:users'],
{
'read:users',
'read:users:name',
'read:users:groups',
'read:users:activity',
'read:users:servers',
},
),
(['read:users:servers'], {'read:users:servers'}),
(['admin:groups'], {'admin:groups'}),
],
)
def test_get_subscopes(db, scopes, subscopes):
"""Test role scopes expansion into their subscopes"""
roles.add_role(db, {'name': 'testing_scopes', 'scopes': scopes})
role = orm.Role.find(db, name='testing_scopes')
response = roles.get_subscopes(role)
assert response == subscopes
db.delete(role)
async def test_load_default_roles(tmpdir, request):
"""Test loading default roles in app.py"""
kwargs = {}
ssl_enabled = getattr(request.module, "ssl_enabled", False)
if ssl_enabled:
kwargs['internal_certs_location'] = str(tmpdir)
hub = MockHub(**kwargs)
hub.init_db()
db = hub.db
await hub.init_roles()
# test default roles loaded to database
assert orm.Role.find(db, 'user') is not None
assert orm.Role.find(db, 'admin') is not None
assert orm.Role.find(db, 'server') is not None
@mark.role
async def test_load_roles_users(tmpdir, request):
"""Test loading predefined roles for users in app.py"""
roles_to_load = [
{
'name': 'teacher',
'description': 'Access users information, servers and groups without create/delete privileges',
'scopes': ['users', 'groups'],
'users': ['cyclops', 'gandalf'],
},
{
'name': 'user',
'description': 'Only read access',
'scopes': ['read:all'],
'users': ['bilbo'],
},
]
kwargs = {'load_roles': roles_to_load}
ssl_enabled = getattr(request.module, "ssl_enabled", False)
if ssl_enabled:
kwargs['internal_certs_location'] = str(tmpdir)
hub = MockHub(**kwargs)
hub.init_db()
db = hub.db
hub.authenticator.admin_users = ['admin']
hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel']
await hub.init_users()
await hub.init_roles()
# test if the 'user' role has been overwritten and assigned
user_role = orm.Role.find(db, 'user')
admin_role = orm.Role.find(db, 'admin')
assert user_role is not None
assert user_role.scopes == ['read:all']
# test if every user has a role (and no duplicates)
# and admins have admin role
for user in db.query(orm.User):
assert len(user.roles) > 0
assert len(user.roles) == len(set(user.roles))
if user.admin:
assert admin_role in user.roles
assert user_role not in user.roles
# test if predefined roles loaded and assigned
teacher_role = orm.Role.find(db, name='teacher')
assert teacher_role is not None
gandalf_user = orm.User.find(db, name='gandalf')
assert teacher_role in gandalf_user.roles
cyclops_user = orm.User.find(db, name='cyclops')
assert teacher_role in cyclops_user.roles
@mark.role
async def test_load_roles_services(tmpdir, request):
services = [
{'name': 'cull_idle', 'api_token': 'some-token'},
{'name': 'user_service', 'api_token': 'some-other-token'},
{'name': 'admin_service', 'api_token': 'secret-token', 'admin': True},
]
roles_to_load = [
{
'name': 'culler',
'description': 'Cull idle servers',
'scopes': ['users:servers', 'admin:servers'],
'services': ['cull_idle'],
},
]
kwargs = {'load_roles': roles_to_load}
ssl_enabled = getattr(request.module, "ssl_enabled", False)
if ssl_enabled:
kwargs['internal_certs_location'] = str(tmpdir)
hub = MockHub(**kwargs)
hub.init_db()
db = hub.db
# clean db of previous services and add testing ones
for service in db.query(orm.Service):
db.delete(service)
db.commit()
for service in services:
orm_service = orm.Service.find(db, name=service['name'])
if orm_service is None:
# not found, create a new one
orm_service = orm.Service(name=service['name'])
db.add(orm_service)
orm_service.admin = service.get('admin', False)
db.commit()
await hub.init_roles()
# test if every service has a role (and no duplicates)
admin_role = orm.Role.find(db, name='admin')
user_role = orm.Role.find(db, name='user')
for service in db.query(orm.Service):
assert len(service.roles) > 0
assert len(service.roles) == len(set(service.roles))
if service.admin:
assert admin_role in service.roles
assert user_role not in service.roles
# test if predefined roles loaded and assigned
culler_role = orm.Role.find(db, name='culler')
cull_idle = orm.Service.find(db, name='cull_idle')
assert culler_role in cull_idle.roles
assert user_role not in cull_idle.roles
# delete the test services
for service in db.query(orm.Service):
db.delete(service)
db.commit()
@mark.role
async def test_load_roles_tokens(tmpdir, request):
services = [
{'name': 'cull_idle', 'admin': True, 'api_token': 'another-secret-token'}
]
user_tokens = {
'secret-token': 'cyclops',
'super-secret-token': 'admin',
}
service_tokens = {
'another-secret-token': 'cull_idle',
}
roles_to_load = [
{
'name': 'culler',
'description': 'Cull idle servers',
'scopes': ['users:servers', 'admin:servers'],
'tokens': ['another-secret-token'],
},
]
kwargs = {
'load_roles': roles_to_load,
'services': services,
'api_tokens': user_tokens,
'service_tokens': service_tokens,
}
ssl_enabled = getattr(request.module, "ssl_enabled", False)
if ssl_enabled:
kwargs['internal_certs_location'] = str(tmpdir)
hub = MockHub(**kwargs)
hub.init_db()
db = hub.db
hub.authenticator.admin_users = ['admin']
hub.authenticator.allowed_users = ['cyclops', 'gandalf']
await hub.init_users()
await hub.init_api_tokens()
await hub.init_roles()
# test if another-secret-token has culler role
service = orm.Service.find(db, 'cull_idle')
culler_role = orm.Role.find(db, 'culler')
token = orm.APIToken.find(db, 'another-secret-token')
assert len(token.roles) == 1
assert culler_role in token.roles
# test if all other tokens have default 'user' role
user_role = orm.Role.find(db, 'user')
sec_token = orm.APIToken.find(db, 'secret-token')
assert user_role in sec_token.roles
s_sec_token = orm.APIToken.find(db, 'super-secret-token')
assert user_role in s_sec_token.roles
@mark.role
@mark.parametrize(
"headers, role_list, status",
[
({}, None, 200),
({}, ['reader'], 200),
({}, ['non-existing'], 404),
({}, ['user_creator'], 403),
],
)
async def test_get_new_token_via_api(app, headers, role_list, status):
user = add_user(app.db, app, name='user')
roles.add_role(app.db, {'name': 'reader', 'scopes': ['read:all']})
roles.add_role(app.db, {'name': 'user_creator', 'scopes': ['admin:users']})
if role_list:
body = json.dumps({'roles': role_list})
else:
body = ''
# request a new token
r = await api_request(
app, 'users/user/tokens', method='post', headers=headers, data=body
)
assert r.status_code == status
if status != 200:
return
# check the new-token reply for roles
reply = r.json()
assert 'token' in reply
assert reply['user'] == 'user'
if not role_list:
assert reply['roles'] == ['user']
else:
assert reply['roles'] == ['reader']
token_id = reply['id']
# delete the token
r = await api_request(app, 'users/user/tokens', token_id, method='delete')
assert r.status_code == 204
# verify deletion
r = await api_request(app, 'users/user/tokens', token_id)
assert r.status_code == 404

View File

@@ -296,7 +296,7 @@ async def test_hubauth_service_token(app, mockservice_url):
)
r.raise_for_status()
reply = r.json()
assert reply == {'kind': 'service', 'name': name, 'admin': False}
assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
assert not r.cookies
# token in ?token parameter
@@ -305,7 +305,7 @@ async def test_hubauth_service_token(app, mockservice_url):
)
r.raise_for_status()
reply = r.json()
assert reply == {'kind': 'service', 'name': name, 'admin': False}
assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
r = await async_requests.get(
public_url(app, mockservice_url) + '/whoami/?token=no-such-token',

View File

@@ -9,6 +9,7 @@ from certipy import Certipy
from jupyterhub import metrics
from jupyterhub import orm
from jupyterhub.objects import Server
from jupyterhub.roles import update_roles
from jupyterhub.utils import url_path_join as ujoin
@@ -111,6 +112,8 @@ def add_user(db, app=None, **kwargs):
for attr, value in kwargs.items():
setattr(orm_user, attr, value)
db.commit()
requested_roles = kwargs.get('roles')
update_roles(db, obj=orm_user, kind='users', roles=requested_roles)
if app:
return app.users[orm_user.id]
else:

View File

@@ -406,9 +406,15 @@ def needs_scope(scope):
if check_scope(self, scope, parsed_scopes, **s_kwargs):
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(
self.request.path, scope, ", ".join(self.scopes)
request_path, scope, ", ".join(self.scopes)
)
)
raise web.HTTPError(

View File

@@ -13,3 +13,4 @@ markers =
services: mark as a services test
user: mark as a test for a user
slow: mark a test as slow
role: mark as a test for roles