diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index d59cfb12..a416e6c8 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -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} diff --git a/jupyterhub/apihandlers/services.py b/jupyterhub/apihandlers/services.py index 068fd645..58c5ed69 100644 --- a/jupyterhub/apihandlers/services.py +++ b/jupyterhub/apihandlers/services.py @@ -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, diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 8f35fe8b..669f45fa 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -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", diff --git a/jupyterhub/app.py b/jupyterhub/app.py index b6d8571a..c7571453 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -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() diff --git a/jupyterhub/dbutil.py b/jupyterhub/dbutil.py index 703de8f4..e3fc7850 100644 --- a/jupyterhub/dbutil.py +++ b/jupyterhub/dbutil.py @@ -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() diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index b98355eb..ba6c92b8 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -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 diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index cc96ca88..90ca63f7 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -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 "" % (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 diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py new file mode 100644 index 00000000..544aa08f --- /dev/null +++ b/jupyterhub/roles.py @@ -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) diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py index 10883cb1..44fd763c 100644 --- a/jupyterhub/services/service.py +++ b/jupyterhub/services/service.py @@ -267,6 +267,7 @@ class Service(LoggingConfigurable): base_url = Unicode() db = Any() orm = Any() + roles = Any() cookie_options = Dict() oauth_provider = Any() diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 8adfc63f..74340562 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -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() diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 53fbb011..47992e90 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -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, diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py index 20e515a2..884921c0 100644 --- a/jupyterhub/tests/test_named_servers.py +++ b/jupyterhub/tests/test_named_servers.py @@ -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 diff --git a/jupyterhub/tests/test_orm.py b/jupyterhub/tests/test_orm.py index 0e31cc85..c761a040 100644 --- a/jupyterhub/tests/test_orm.py +++ b/jupyterhub/tests/test_orm.py @@ -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 diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py new file mode 100644 index 00000000..bdc8b98a --- /dev/null +++ b/jupyterhub/tests/test_roles.py @@ -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 diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index fb657385..d6160a63 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -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', diff --git a/jupyterhub/tests/utils.py b/jupyterhub/tests/utils.py index c09a7247..acbe1cac 100644 --- a/jupyterhub/tests/utils.py +++ b/jupyterhub/tests/utils.py @@ -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: diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 26bae333..0de66778 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -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( diff --git a/pytest.ini b/pytest.ini index dff95321..4b499de6 100644 --- a/pytest.ini +++ b/pytest.ini @@ -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