diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index a416e6c8..e6524cff 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -222,6 +222,7 @@ class APIHandler(BaseHandler): 'kind': 'group', 'name': group.name, 'users': [u.name for u in group.users], + 'roles': [r.name for r in group.roles], } def service_model(self, service): @@ -233,9 +234,15 @@ class APIHandler(BaseHandler): 'roles': [r.name for r in service.roles], } - _user_model_types = {'name': str, 'admin': bool, 'groups': list, 'auth_state': dict} + _user_model_types = { + 'name': str, + 'admin': bool, + 'groups': list, + 'roles': list, + 'auth_state': dict, + } - _group_model_types = {'name': str, 'users': list} + _group_model_types = {'name': str, 'users': list, 'roles': list} def _check_model(self, model, model_types, name): """Check a model provided by a REST API request diff --git a/jupyterhub/app.py b/jupyterhub/app.py index c7571453..28c70e29 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -326,7 +326,8 @@ class JupyterHub(Application): 'scopes': ['users', 'groups'], 'users': ['cyclops', 'gandalf'], 'services': [], - 'tokens': [] + 'tokens': [], + 'groups': [] } ] @@ -1827,7 +1828,7 @@ class JupyterHub(Application): async def init_roles(self): """Load default and predefined roles into the database""" db = self.db - role_bearers = ['users', 'services', 'tokens'] + role_bearers = ['groups', 'users', 'services', 'tokens'] # load default roles default_roles = roles.get_default_roles() @@ -1857,11 +1858,18 @@ class JupyterHub(Application): ) # make sure all users, services and tokens have at least one role (update with default) + # groups can be without a role but all group members should have the same role(s) as the group 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) + if bearer == 'groups': + for group in db.query(Class): + for user in group.users: + rolenames = roles.get_rolenames(group.roles) + roles.update_roles(db, obj=user, kind='users', roles=rolenames) + else: + 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): diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index 90ca63f7..42089c10 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -166,6 +166,14 @@ api_token_role_map = Table( Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), ) +# group:role many:many mapping table +group_role_map = Table( + 'group_role_map', + Base.metadata, + Column('group_id', ForeignKey('groups.id', ondelete='CASCADE'), primary_key=True), + Column('role_id', ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), +) + class Role(Base): """User Roles""" @@ -178,6 +186,7 @@ class Role(Base): 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') + groups = relationship('Group', secondary='group_role_map', backref='roles') def __repr__(self): return "<%s %s (%s) - scopes: %s>" % ( diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index 544aa08f..6291a3ce 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -147,12 +147,26 @@ def get_orm_class(kind): Class = orm.Service elif kind == 'tokens': Class = orm.APIToken + elif kind == 'groups': + Class = orm.Group else: - raise ValueError("kind must be users, services or tokens, not %r" % kind) + raise ValueError( + "kind must be users, services, tokens or groups, not %r" % kind + ) return Class +def get_rolenames(role_list): + + """Return a list of rolenames from a list of roles""" + + rolenames = [] + for role in role_list: + rolenames.append(role.name) + return rolenames + + def existing_only(func): """Decorator for checking if objects and roles exist""" @@ -176,7 +190,7 @@ def existing_only(func): @existing_only def add_obj(db, objname, kind, rolename): - """Adds a role for users, services or tokens""" + """Adds a role for users, services, tokens or groups""" if rolename not in objname.roles: objname.roles.append(rolename) @@ -250,12 +264,28 @@ def update_roles(db, obj, kind, roles=None): else: add_obj(db, objname=obj.name, kind=kind, rolename=rolename) else: + # groups can be without a role + if Class == orm.Group: + pass # tokens can have only 'user' role as default # assign the default only for user tokens - if Class == orm.APIToken: + elif 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) + + +def mock_roles(app, name, kind): + + """Loads and assigns default roles for mocked objects""" + + Class = get_orm_class(kind) + + obj = Class.find(app.db, name=name) + default_roles = get_default_roles() + for role in default_roles: + add_role(app.db, role) + update_roles(db=app.db, obj=obj, kind=kind) diff --git a/jupyterhub/tests/conftest.py b/jupyterhub/tests/conftest.py index 104ca635..45bfd203 100644 --- a/jupyterhub/tests/conftest.py +++ b/jupyterhub/tests/conftest.py @@ -44,6 +44,7 @@ import jupyterhub.services.service from . import mocking from .. import crypto from .. import orm +from ..roles import mock_roles from ..utils import random_port from .mocking import MockHub from .test_services import mockservice_cmd @@ -245,6 +246,7 @@ def _mockservice(request, app, url=False): ): app.services = [spec] app.init_services() + mock_roles(app, name, 'services') assert name in app._service_map service = app._service_map[name] diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index d2ccbba4..df2dd302 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1446,7 +1446,7 @@ async def test_groups_list(app): r = await api_request(app, 'groups') r.raise_for_status() reply = r.json() - assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': []}] + assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []}] @mark.group @@ -1481,7 +1481,12 @@ async def test_group_get(app): r = await api_request(app, 'groups/alphaflight') r.raise_for_status() reply = r.json() - assert reply == {'kind': 'group', 'name': 'alphaflight', 'users': ['sasquatch']} + assert reply == { + 'kind': 'group', + 'name': 'alphaflight', + 'users': ['sasquatch'], + 'roles': [], + } @mark.group @@ -1594,7 +1599,7 @@ async def test_get_services(app, mockservice_url): mockservice.name: { 'name': mockservice.name, 'admin': True, - 'roles': [], + 'roles': ['admin'], 'command': mockservice.command, 'pid': mockservice.proc.pid, 'prefix': mockservice.server.base_url, @@ -1620,7 +1625,7 @@ async def test_get_service(app, mockservice_url): assert service == { 'name': mockservice.name, 'admin': True, - 'roles': [], + 'roles': ['admin'], 'command': mockservice.command, 'pid': mockservice.proc.pid, 'prefix': mockservice.server.base_url, diff --git a/jupyterhub/tests/test_roles.py b/jupyterhub/tests/test_roles.py index bdc8b98a..90c108fd 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -26,6 +26,18 @@ def test_orm_roles(db): db.add(service_role) db.commit() + group_role = orm.Role(name='group', scopes=['read:users']) + db.add(group_role) + db.commit() + + group_role = orm.Role(name='group', scopes=['read:users']) + db.add(group_role) + db.commit() + + group_role = orm.Role(name='group', scopes=['read:users']) + db.add(group_role) + db.commit() + user = orm.User(name='falafel') db.add(user) db.commit() @@ -34,20 +46,30 @@ def test_orm_roles(db): db.add(service) db.commit() + group = orm.Group(name='fast-food') + db.add(group) + db.commit() + assert user_role.users == [] assert user_role.services == [] + assert user_role.groups == [] assert service_role.users == [] assert service_role.services == [] + assert service_role.groups == [] assert user.roles == [] assert service.roles == [] + assert group.roles == [] user_role.users.append(user) service_role.services.append(service) + group_role.groups.append(group) db.commit() assert user_role.users == [user] assert user.roles == [user_role] assert service_role.services == [service] assert service.roles == [service_role] + assert group_role.groups == [group] + assert group.roles == [group_role] # check token creation without specifying its role # assigns it the default 'user' role @@ -67,16 +89,22 @@ def test_orm_roles(db): db.commit() assert user_role.users == [] assert user_token not in user_role.tokens - # check deleting the service token removes it from 'service' role + # 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 + # check deleting the service_role removes it from service.roles db.delete(service_role) db.commit() assert service.roles == [] + # check deleting the group removes it from group_roles + db.delete(group) + db.commit() + assert group_role.groups == [] + # clean up db db.delete(service) + db.delete(group_role) db.commit() @@ -310,6 +338,76 @@ async def test_load_roles_services(tmpdir, request): db.commit() +@mark.role +async def test_load_roles_groups(tmpdir, request): + """Test loading predefined roles for groups in app.py""" + groups_to_load = { + 'group1': ['gandalf'], + 'group2': ['bilbo', 'gargamel'], + 'group3': ['cyclops'], + } + roles_to_load = [ + { + 'name': 'assistant', + 'description': 'Access users information only', + 'scopes': ['read:users'], + 'groups': ['group2'], + }, + { + 'name': 'head', + 'description': 'Whole user access', + 'scopes': ['users', 'admin:users'], + 'groups': ['group3'], + }, + ] + kwargs = {'load_groups': groups_to_load, '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.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel'] + await hub.init_users() + await hub.init_groups() + await hub.init_roles() + + assist_role = orm.Role.find(db, name='assistant') + head_role = orm.Role.find(db, name='head') + user_role = orm.Role.find(db, name='user') + + group1 = orm.Group.find(db, name='group1') + group2 = orm.Group.find(db, name='group2') + group3 = orm.Group.find(db, name='group3') + + gandalf = orm.User.find(db, name='gandalf') + bilbo = orm.User.find(db, name='bilbo') + gargamel = orm.User.find(db, name='gargamel') + cyclops = orm.User.find(db, name='cyclops') + + # test group roles + assert group1.roles == [] + assert group2 in assist_role.groups + assert group3 in head_role.groups + + # test group members' roles + assert assist_role in bilbo.roles + assert assist_role in gargamel.roles + assert head_role in cyclops.roles + + # check the default user_role assignment + # FIXME - should users with group roles still have default? + assert user_role in gandalf.roles + assert user_role not in bilbo.roles + assert user_role not in gargamel.roles + assert user_role not in cyclops.roles + + +# FIXME +# @mark.role +# async def test_group_roles_delete_cascade(tmpdir, request): + + @mark.role async def test_load_roles_tokens(tmpdir, request): services = [