diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 8ebc15d4..8bd05ecc 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -222,7 +222,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 3d1f7bcc..10ee1fec 100644 --- a/jupyterhub/apihandlers/services.py +++ b/jupyterhub/apihandlers/services.py @@ -18,6 +18,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/app.py b/jupyterhub/app.py index 70483993..6618c6f0 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1834,26 +1834,39 @@ class JupyterHub(Application): role = orm.Role.find(db, predef_role['name']) # handle users - for username in predef_role['users']: - username = self.authenticator.normalize_username(username) - if not ( - await maybe_future(self.authenticator.check_allowed(username, None)) - ): - raise ValueError( - "Username %r is not in Authenticator.allowed_users" % username - ) - user = orm.User.find(db, name=username) - if user is None: - if not self.authenticator.validate_username(username): - raise ValueError("Role username %r is not valid" % username) - user = orm.User(name=username) - db.add(user) - roles.add_user(db, user=user, role=role) + if 'users' in predef_role.keys(): + for username in predef_role['users']: + username = self.authenticator.normalize_username(username) + if not ( + await maybe_future( + self.authenticator.check_allowed(username, None) + ) + ): + raise ValueError( + "Username %r is not in Authenticator.allowed_users" + % username + ) + user = orm.User.find(db, name=username) + if user is None: + raise ValueError("%r does not exist" % username) + else: + roles.add_user(db, user=user, role=role) - # make sure all users have at least one role (update with default) - for user in db.query(orm.User): - if len(user.roles) < 1: - roles.update_roles(db, user) + # handle services + if 'services' in predef_role.keys(): + for servicename in predef_role['services']: + service = orm.Service.find(db, name=servicename) + if service is None: + raise ValueError("%r does not exist" % servicename) + else: + roles.add_user(db, user=service, role=role) + + # make sure all users and services have at least one role (update with default) + Classes = [orm.User, orm.Service] + for ormClass in Classes: + for obj in db.query(ormClass): + if len(obj.roles) < 1: + roles.update_roles(db, obj) db.commit() @@ -1968,6 +1981,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, @@ -2435,9 +2449,9 @@ class JupyterHub(Application): self.init_oauth() await self.init_users() await self.init_groups() - await self.init_roles() 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/orm.py b/jupyterhub/orm.py index 1a518bf0..66d9f31d 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -141,6 +141,16 @@ user_role_map = Table( 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), +) + class Role(Base): """User Roles""" @@ -151,6 +161,7 @@ class Role(Base): 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') def __repr__(self): return "<%s %s (%s) - scopes: %s>" % ( diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index 53e0c637..a56b3344 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -55,6 +55,8 @@ def add_role(db, role_dict): else: role.description = role_dict['description'] role.scopes = role_dict['scopes'] + role.users = [] + role.services = [] db.commit() 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/populate_db.py b/jupyterhub/tests/populate_db.py index fd8cb9a1..fec3d7e9 100644 --- a/jupyterhub/tests/populate_db.py +++ b/jupyterhub/tests/populate_db.py @@ -10,8 +10,7 @@ from datetime import datetime import jupyterhub from jupyterhub import orm -# FIXME - for later versions of jupyterhub add code to test Roles -# from jupyterhub.orm import Role +# FIXME - for later versions of jupyterhub add code to test roles def populate_db(url): diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index f46c22c6..966dadcf 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1536,6 +1536,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, @@ -1561,6 +1562,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_roles.py b/jupyterhub/tests/test_roles.py index 8b20fcf3..1e4ebe1d 100644 --- a/jupyterhub/tests/test_roles.py +++ b/jupyterhub/tests/test_roles.py @@ -4,33 +4,45 @@ from pytest import mark from .. import orm from .. import roles +from ..utils import maybe_future from .mocking import MockHub @mark.role -def test_roles(db): +def test_orm_roles(db): """Test orm roles setup""" user = orm.User(name='falafel') db.add(user) + service = orm.Service(name='kebab') + db.add(service) role = orm.Role(name='default') db.add(role) db.commit() assert role.users == [] + assert role.services == [] assert user.roles == [] + assert service.roles == [] role.users.append(user) + role.services.append(service) db.commit() assert role.users == [user] assert user.roles == [role] + assert role.services == [service] + assert service.roles == [role] db.delete(user) db.commit() assert role.users == [] db.delete(role) + db.commit() + assert service.roles == [] + db.delete(service) + db.commit() @mark.role -def test_role_delete_cascade(db): +def test_orm_role_delete_cascade(db): """Orm roles cascade""" user1 = orm.User(name='user1') user2 = orm.User(name='user2') @@ -93,8 +105,25 @@ def test_role_delete_cascade(db): @mark.role -async def test_load_roles(tmpdir, request): - """Test loading default and predefined roles in app.py""" +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', @@ -106,7 +135,7 @@ async def test_load_roles(tmpdir, request): 'name': 'user', 'description': 'Only read access', 'scopes': ['read:all'], - 'users': ['test_user'], + 'users': ['bilbo'], }, ] kwargs = {'load_roles': roles_to_load} @@ -116,20 +145,28 @@ async def test_load_roles(tmpdir, request): 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() + for user in db.query(orm.User): + print(user.name) await hub.init_roles() - # test if the 'user' role has been overwritten + + # 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 other 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 - # test if every existing user has a role (and no duplicates) + + # 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 @@ -137,3 +174,53 @@ async def test_load_roles(tmpdir, request): 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): + 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(test_clean_db=False, **kwargs) + hub.init_db() + db = hub.db + # add test services to db + services = [ + {'name': 'cull_idle', 'admin': False}, + {'name': 'user_service', 'admin': False}, + {'name': 'admin_service', 'admin': True}, + ] + for service_specs in services: + service = orm.Service.find(db, service_specs['name']) + if service is None: + service = orm.Service( + name=service_specs['name'], admin=service_specs['admin'] + ) + db.add(service) + 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 diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index 867d1d97..a0bf5d43 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -295,7 +295,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 @@ -304,7 +304,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',