diff --git a/jupyterhub/apihandlers/__init__.py b/jupyterhub/apihandlers/__init__.py index 39733829de..052e010c20 100644 --- a/jupyterhub/apihandlers/__init__.py +++ b/jupyterhub/apihandlers/__init__.py @@ -1,6 +1,6 @@ -from . import auth, groups, hub, proxy, services, users +from . import auth, groups, hub, proxy, roles, services, users from .base import * default_handlers = [] -for mod in (auth, hub, proxy, users, groups, services): +for mod in (auth, hub, proxy, users, groups, services, roles): default_handlers.extend(mod.default_handlers) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 88844cb5d4..b2109c53c0 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -366,6 +366,22 @@ def group_model(self, group): model = self._filter_model(model, access_map, group, 'group') return model + def role_model(self, role): + """Get the JSON model for a Role object""" + model = { + 'kind': 'role', + 'name': role.name, + 'groups': [g.name for g in role.groups], + 'users': [u.name for u in role.users], + 'scopes': [s for s in role.scopes], + 'services': [s.name for s in role.services], + } + access_map = { + 'read:roles': {'kind', 'name', 'users', 'scopes', 'groups', 'services'}, + } + model = self._filter_model(model, access_map, role, 'role') + return model + def service_model(self, service): """Get the JSON model for a Service object""" model = { @@ -407,6 +423,13 @@ def service_model(self, service): } _group_model_types = {'name': str, 'users': list, 'roles': list} + _role_model_types = { + 'name': str, + 'users': list, + 'groups': list, + 'scopes': list, + 'services': list, + } def _check_model(self, model, model_types, name): """Check a model provided by a REST API request @@ -446,6 +469,15 @@ def _check_group_model(self, model): 400, ("group names must be str, not %r", type(groupname)) ) + def _check_role_model(self, model): + """Check a request-provided role model from a REST API""" + self._check_model(model, self._role_model_types, 'role') + for rolename in model.get('roles', []): + if not isinstance(rolename, str): + raise web.HTTPError( + 400, ("Role names must be str, not %r", type(rolename)) + ) + def get_api_pagination(self): default_limit = self.settings["api_page_default_limit"] max_limit = self.settings["api_page_max_limit"] diff --git a/jupyterhub/apihandlers/roles.py b/jupyterhub/apihandlers/roles.py new file mode 100644 index 0000000000..4df951d37c --- /dev/null +++ b/jupyterhub/apihandlers/roles.py @@ -0,0 +1,165 @@ +"""Role handlers""" +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import json +from ossaudiodev import SNDCTL_COPR_SENDMSG + +from tornado import web + +from .. import orm +from ..scopes import Scope, needs_scope +from .base import APIHandler + + +class _RoleAPIHandler(APIHandler): + def find_role(self, role_name): + """Find and return a role by name. + + Raise 404 if not found. + """ + role = orm.Role.find(self.db, name=role_name) + if role is None: + raise web.HTTPError(404, "No such role: %s", role_name) + return role + + +class RoleAPIHandler(_RoleAPIHandler): + """View and modify roles by name""" + + @needs_scope('read:roles') + def get(self, role_name): + role = self.find_role(role_name) + print(role) + self.write(json.dumps(self.role_model(role))) + + @needs_scope('admin:groups') + async def post(self, role_name): + """POST creates a role""" + model = self.get_json_body() + if model is None: + model = {} + else: + self._check_role_model(model) + + existing = orm.Role.find(self.db, name=role_name) + if existing is not None: + raise web.HTTPError(409, "Role %s already exists" % role_name) + # check that everything exists + scopes = model.get('scopes', []) + servicenames = model.get('services', []) + usernames = model.get('users', []) + groupnames = model.pop("groups", []) + # check if scopes exist + if scopes: + # avoid circular import + from ..scopes import _check_scopes_exist + + try: + _check_scopes_exist(scopes, who_for=f"role {role_name}") + except: + raise web.HTTPError(409, "One of the scopes does not exist") + + services = [] + for name in servicenames: + existing = orm.Service.find(self.db, name=name) + # Non existing groups are ignored and won't be created + if existing is not None: + services.append(existing) + + users = [] + for name in usernames: + existing = orm.User.find(self.db, name=name) + # Non existing groups are ignored and won't be created + if existing is not None: + users.append(existing) + + groups = [] + for name in groupnames: + existing = orm.Group.find(self.db, name=name) + # Non existing groups are ignored and won't be created + if existing is not None: + groups.append(existing) + + # create the role + self.log.info("Creating new role %s with %i scopes", role_name, len(scopes)) + self.log.debug("Scopes: %s", scopes) + role = orm.Role( + name=role_name, scopes=scopes, groups=groups, services=services, users=users + ) + print(role) + self.db.add(role) + self.db.commit() + self.write(json.dumps(self.role_model(role))) + self.set_status(201) + + @needs_scope('admin:groups') + def put(self, role_name): + """PUT edits a role""" + model = self.get_json_body() + if model is None: + model = {} + else: + self._check_role_model(model) + + existing = orm.Role.find(self.db, name=role_name) + if existing is None: + raise web.HTTPError(409, "Role %s does not exist" % role_name) + # check that everything exists + scopes = model.get('scopes', []) + servicenames = model.get('services', []) + usernames = model.get('users', []) + groupnames = model.pop("groups", []) + # check if scopes exist + if scopes: + # avoid circular import + from ..scopes import _check_scopes_exist + + try: + _check_scopes_exist(scopes, who_for=f"role {role_name}") + except: + raise web.HTTPError(409, "One of the scopes does not exist") + services = [] + for name in servicenames: + existing = orm.Service.find(self.db, name=name) + # Non existing groups are ignored and won't be created + if existing is not None: + services.append(existing) + users = [] + for name in usernames: + existing = orm.User.find(self.db, name=name) + # Non existing groups are ignored and won't be created + if existing is not None: + users.append(existing) + groups = [] + for name in groupnames: + existing = orm.Group.find(self.db, name=name) + # Non existing groups are ignored and won't be created + if existing is not None: + groups.append(existing) + + # create the role + self.log.info("Editing role %s with %i scopes", role_name, len(scopes)) + self.log.debug("Scopes: %s", scopes) + role = self.find_role(role_name) + role.scopes = scopes + role.groups = groups + role.services = services + role.users = users + print(role) + self.db.commit() + self.write(json.dumps(self.role_model(role))) + self.set_status(201) + + @needs_scope('delete:groups') + def delete(self, role_name): + """Delete a role by name""" + role = self.find_role(role_name) + self.log.info("Deleting role %s", role_name) + self.db.delete(role) + self.db.commit() + self.set_status(204) + + +default_handlers = [ + (r"/api/roles/([^/]+)", RoleAPIHandler), +] diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index fa96b22d0f..525ec8b9b7 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1883,6 +1883,113 @@ async def test_auth_managed_groups(request, app, group, user): assert r.status_code == 400 +# ----------------- +# Role API tests +# ----------------- + + +@mark.role +async def test_role_get(app): + role = orm.Role(name='alphaflight') + app.db.add(role) + app.db.commit() + role = orm.Role.find(app.db, name='alphaflight') + app.db.commit() + + r = await api_request(app, 'roles/runaways') + assert r.status_code == 404 + + r = await api_request(app, 'roles/alphaflight') + r.raise_for_status() + reply = r.json() + assert reply == { + 'kind': 'role', + 'name': 'alphaflight', + 'groups': [], + 'users': [], + 'scopes': [], + 'services': [], + } + + +@mark.role +async def test_role_create_delete(app): + db = app.db + user = add_user(app.db, app=app, name='sasquatch') + r = await api_request(app, 'roles/runaways', method='delete') + assert r.status_code == 404 + + assert orm.Role.find(db, name='new') is None + + r = await api_request( + app, + 'roles/omegaflight', + method='post', + data=json.dumps({'users': ['sasquatch']}), + ) + r.raise_for_status() + + omegaflight = orm.Role.find(db, name='omegaflight') + sasquatch = find_user(db, name='sasquatch') + assert omegaflight in sasquatch.roles + assert sasquatch in omegaflight.users + + # create duplicate raises 400 + r = await api_request(app, 'roles/omegaflight', method='post') + assert r.status_code == 409 + + r = await api_request(app, 'roles/omegaflight', method='delete') + assert r.status_code == 204 + assert omegaflight not in sasquatch.roles + assert orm.Group.find(db, name='omegaflight') is None + + # delete nonexistent gives 404 + r = await api_request(app, 'roles/omegaflight', method='delete') + assert r.status_code == 404 + + +@mark.role +async def test_role_edit(app): + db = app.db + role = orm.Role(name='alpharole') + app.db.add(role) + app.db.commit() + # correct scopes + + # update scopes of role by adding correct scopes + scopes = ['admin:groups', 'read:users'] + r = await api_request( + app, + 'roles/alpharole', + method='put', + data=json.dumps({'scopes': scopes}), + ) + r.raise_for_status() + role = orm.Role.find(db, name='alpharole') + assert sorted(scope for scope in role.scopes) == sorted(scopes) + + # update scopes of role by adding wrong scopes + scopes_wrong = ['read12:groups', 'reads:wrongscope'] + r = await api_request( + app, + 'roles/alpharole', + method='put', + data=json.dumps({'scopes': scopes_wrong}), + ) + assert r.status_code == 409 + + # removing scopes with put request + + r = await api_request( + app, + 'roles/alpharole', + method='put', + data=json.dumps({'scopes': []}), + ) + r.raise_for_status() + assert role.scopes == [] + + # ----------------- # Service API tests # -----------------