From be7f1234656ee54a68b1465a2285950945e8579e Mon Sep 17 00:00:00 2001 From: vpopescu Date: Thu, 14 Jul 2022 16:00:15 +0200 Subject: [PATCH 01/13] Added first concept of endpoints --- jupyterhub/apihandlers/base.py | 27 +++++++++- jupyterhub/apihandlers/groups.py | 54 +++++++++++++++++++- jupyterhub/apihandlers/roles.py | 85 ++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 jupyterhub/apihandlers/roles.py diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index b6ccb4e8f3..9611bb9fdb 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -321,6 +321,23 @@ 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 Group 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.name for s in role.scopes], + 'services': [s.name for s in role.services] + } + access_map = { + 'read:roles': {'kind', 'name', 'users', 'groups', 'scopes', 'services'}, + 'read:roles:name': {'kind', 'name'}, + } + 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 = { @@ -362,7 +379,7 @@ def service_model(self, service): } _group_model_types = {'name': str, 'users': list, 'roles': list} - + _role_model_types = {'name': str, 'users': list, 'roles': list, 'groups': list, 'scopes': list, 'services': list} def _check_model(self, model, model_types, name): """Check a model provided by a REST API request @@ -400,6 +417,14 @@ def _check_group_model(self, model): raise web.HTTPError( 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"] diff --git a/jupyterhub/apihandlers/groups.py b/jupyterhub/apihandlers/groups.py index c5799f1587..9b850e4677 100644 --- a/jupyterhub/apihandlers/groups.py +++ b/jupyterhub/apihandlers/groups.py @@ -9,7 +9,11 @@ from ..scopes import Scope, needs_scope from .base import APIHandler - +def convertTuple(tup): + str = '' + for item in tup: + str = str + item + return str class _GroupAPIHandler(APIHandler): def _usernames_to_users(self, usernames): """Turn a list of usernames into user objects""" @@ -104,7 +108,7 @@ async def post(self): class GroupAPIHandler(_GroupAPIHandler): """View and modify groups by name""" - + @needs_scope('read:groups', 'read:groups:name', 'read:roles:groups') def get(self, group_name): group = self.find_group(group_name) @@ -136,7 +140,53 @@ async def post(self, group_name): self.db.commit() self.write(json.dumps(self.group_model(group))) self.set_status(201) + def put(self, group_name): + """Edit a group's permissions by name""" + self.check_authenticator_managed_groups() + model = self.get_json_body() + if model is None: + model = {} + else: + self._check_group_model(model) + + existing = orm.Group.find(self.db, name=group_name) + if existing is not None: + usernames = model.get('users', []) + # check that users exist + users = self._usernames_to_users(usernames) + + # select the group + group = self.find_group(group_name) + # create group roles: + if group_name.split(':')[1] == 'instructor': + role = orm.Role(name=group_name, scopes=['admin-ui', + convertTuple(('list:users!group=',group_name.split(':')[0],':student')), + convertTuple(('list:users!group=',group_name.split(':')[0],':instructor')), + convertTuple(('groups!group=',group_name.split(':')[0],':student')), + convertTuple(('groups!group=',group_name.split(':')[0],':instructor')), + convertTuple(('read:servers!group=',group_name.split(':')[0],':student')), + convertTuple(('read:servers!group=',group_name.split(':')[0],':instructor')), + convertTuple(('access:servers!group=',group_name.split(':')[0],':student')), + convertTuple(('access:servers!group=',group_name.split(':')[0],':instructor'))] + ) + role.groups.append(group) + self.db.commit() + if group_name.split(':')[1] == 'student': + role = orm.Role(name=group_name, scopes=['admin-ui', + convertTuple(('list:users!group=%s',group_name.split(':')[0],':instructor')), + convertTuple(('groups!group=%s',group_name.split(':')[0],':student'))] + ) + + role.groups.append(group) + self.db.commit() + + self.db.commit() + self.write(json.dumps(self.group_model(group))) + self.set_status(201) + else: + raise web.HTTPError(409, "Group %s does not exist" % group_name) + @needs_scope('delete:groups') def delete(self, group_name): """Delete a group by name""" diff --git a/jupyterhub/apihandlers/roles.py b/jupyterhub/apihandlers/roles.py new file mode 100644 index 0000000000..28f186a2ab --- /dev/null +++ b/jupyterhub/apihandlers/roles.py @@ -0,0 +1,85 @@ +"""Role handlers""" +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import json + +from tornado import web + +from .. import orm +from ..scopes import Scope, needs_scope +from .base import APIHandler + +def convertTuple(tup): + str = '' + for item in tup: + str = str + item + return str + +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', 'read:roles:name') + def get(self, role_name): + role = self.find_role(role_name) + self.write(json.dumps(self.role_model(role))) + + @needs_scope('admin:roles') + 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) + + scopes = model.get('scopes', []) + # check that groups exist + groupnames = model.pop("groups", []) + groups=[] + for name in groupnames: + existing = orm.Group.find(self.db, name=name) + if existing is not None: + groups.append(existing) + else: + print(Exception) + # 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) + self.db.add(role) + self.db.commit() + self.write(json.dumps(self.role_model(role))) + self.set_status(201) + + + @needs_scope('delete:roles') + 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), +] From ff59b474c31eaa30029dd9ef9c8f7e9313c15126 Mon Sep 17 00:00:00 2001 From: vpopescu Date: Thu, 14 Jul 2022 17:42:22 +0200 Subject: [PATCH 02/13] Added working endpoint for roles --- jupyterhub/apihandlers/__init__.py | 4 +- jupyterhub/apihandlers/base.py | 5 +- jupyterhub/apihandlers/roles.py | 91 +++++++++++++++++++++++++----- 3 files changed, 80 insertions(+), 20 deletions(-) diff --git a/jupyterhub/apihandlers/__init__.py b/jupyterhub/apihandlers/__init__.py index 39733829de..ad9a8dc1b9 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, services, users, roles 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 9611bb9fdb..84d6edda87 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -322,18 +322,17 @@ def group_model(self, group): return model def role_model(self, role): - """Get the JSON model for a Group object""" + """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.name for s in role.scopes], + 'scopes': [s for s in role.scopes], 'services': [s.name for s in role.services] } access_map = { 'read:roles': {'kind', 'name', 'users', 'groups', 'scopes', 'services'}, - 'read:roles:name': {'kind', 'name'}, } model = self._filter_model(model, access_map, role, 'role') return model diff --git a/jupyterhub/apihandlers/roles.py b/jupyterhub/apihandlers/roles.py index 28f186a2ab..a729479598 100644 --- a/jupyterhub/apihandlers/roles.py +++ b/jupyterhub/apihandlers/roles.py @@ -2,6 +2,7 @@ # 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 @@ -9,12 +10,6 @@ from ..scopes import Scope, needs_scope from .base import APIHandler -def convertTuple(tup): - str = '' - for item in tup: - str = str + item - return str - class _RoleAPIHandler(APIHandler): def find_role(self, role_name): @@ -31,12 +26,13 @@ def find_role(self, role_name): class RoleAPIHandler(_RoleAPIHandler): """View and modify roles by name""" - @needs_scope('read:roles', 'read:roles: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:roles') + @needs_scope('admin:groups') async def post(self, role_name): """POST creates a role""" model = self.get_json_body() @@ -48,28 +44,93 @@ async def post(self, role_name): 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', []) - # check that groups exist + servicenames = model.get('services', []) + usernames = model.get('users', []) groupnames = model.pop("groups", []) + + 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) - else: - print(Exception) + # 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) + 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", []) + + 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) - @needs_scope('delete:roles') + # 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) From b00ffb2714ccfc769c3a7ef19d78639ec0839093 Mon Sep 17 00:00:00 2001 From: vpopescu Date: Fri, 15 Jul 2022 11:30:06 +0200 Subject: [PATCH 03/13] Removed groups changes --- jupyterhub/apihandlers/groups.py | 54 ++------------------------------ 1 file changed, 2 insertions(+), 52 deletions(-) diff --git a/jupyterhub/apihandlers/groups.py b/jupyterhub/apihandlers/groups.py index 9b850e4677..c5799f1587 100644 --- a/jupyterhub/apihandlers/groups.py +++ b/jupyterhub/apihandlers/groups.py @@ -9,11 +9,7 @@ from ..scopes import Scope, needs_scope from .base import APIHandler -def convertTuple(tup): - str = '' - for item in tup: - str = str + item - return str + class _GroupAPIHandler(APIHandler): def _usernames_to_users(self, usernames): """Turn a list of usernames into user objects""" @@ -108,7 +104,7 @@ async def post(self): class GroupAPIHandler(_GroupAPIHandler): """View and modify groups by name""" - + @needs_scope('read:groups', 'read:groups:name', 'read:roles:groups') def get(self, group_name): group = self.find_group(group_name) @@ -140,53 +136,7 @@ async def post(self, group_name): self.db.commit() self.write(json.dumps(self.group_model(group))) self.set_status(201) - def put(self, group_name): - """Edit a group's permissions by name""" - self.check_authenticator_managed_groups() - model = self.get_json_body() - if model is None: - model = {} - else: - self._check_group_model(model) - - existing = orm.Group.find(self.db, name=group_name) - if existing is not None: - usernames = model.get('users', []) - # check that users exist - users = self._usernames_to_users(usernames) - - # select the group - group = self.find_group(group_name) - # create group roles: - if group_name.split(':')[1] == 'instructor': - role = orm.Role(name=group_name, scopes=['admin-ui', - convertTuple(('list:users!group=',group_name.split(':')[0],':student')), - convertTuple(('list:users!group=',group_name.split(':')[0],':instructor')), - convertTuple(('groups!group=',group_name.split(':')[0],':student')), - convertTuple(('groups!group=',group_name.split(':')[0],':instructor')), - convertTuple(('read:servers!group=',group_name.split(':')[0],':student')), - convertTuple(('read:servers!group=',group_name.split(':')[0],':instructor')), - convertTuple(('access:servers!group=',group_name.split(':')[0],':student')), - convertTuple(('access:servers!group=',group_name.split(':')[0],':instructor'))] - ) - role.groups.append(group) - self.db.commit() - if group_name.split(':')[1] == 'student': - role = orm.Role(name=group_name, scopes=['admin-ui', - convertTuple(('list:users!group=%s',group_name.split(':')[0],':instructor')), - convertTuple(('groups!group=%s',group_name.split(':')[0],':student'))] - ) - - role.groups.append(group) - self.db.commit() - - self.db.commit() - self.write(json.dumps(self.group_model(group))) - self.set_status(201) - else: - raise web.HTTPError(409, "Group %s does not exist" % group_name) - @needs_scope('delete:groups') def delete(self, group_name): """Delete a group by name""" From 83d36570c603e296057adc9b598df760d5217477 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 15 Jul 2022 09:31:56 +0000 Subject: [PATCH 04/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyterhub/apihandlers/__init__.py | 2 +- jupyterhub/apihandlers/base.py | 13 +++++++-- jupyterhub/apihandlers/roles.py | 44 ++++++++++++++++-------------- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/jupyterhub/apihandlers/__init__.py b/jupyterhub/apihandlers/__init__.py index ad9a8dc1b9..052e010c20 100644 --- a/jupyterhub/apihandlers/__init__.py +++ b/jupyterhub/apihandlers/__init__.py @@ -1,4 +1,4 @@ -from . import auth, groups, hub, proxy, services, users, roles +from . import auth, groups, hub, proxy, roles, services, users from .base import * default_handlers = [] diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 84d6edda87..3697e75eed 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -329,7 +329,7 @@ def role_model(self, role): '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] + 'services': [s.name for s in role.services], } access_map = { 'read:roles': {'kind', 'name', 'users', 'groups', 'scopes', 'services'}, @@ -378,7 +378,15 @@ def service_model(self, service): } _group_model_types = {'name': str, 'users': list, 'roles': list} - _role_model_types = {'name': str, 'users': list, 'roles': list, 'groups': list, 'scopes': list, 'services': list} + _role_model_types = { + 'name': str, + 'users': list, + 'roles': list, + 'groups': list, + 'scopes': list, + 'services': list, + } + def _check_model(self, model, model_types, name): """Check a model provided by a REST API request @@ -416,6 +424,7 @@ def _check_group_model(self, model): raise web.HTTPError( 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') diff --git a/jupyterhub/apihandlers/roles.py b/jupyterhub/apihandlers/roles.py index a729479598..79002f5ce3 100644 --- a/jupyterhub/apihandlers/roles.py +++ b/jupyterhub/apihandlers/roles.py @@ -10,8 +10,8 @@ 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. @@ -25,7 +25,7 @@ def find_role(self, role_name): class RoleAPIHandler(_RoleAPIHandler): """View and modify roles by name""" - + @needs_scope('read:roles') def get(self, role_name): role = self.find_role(role_name) @@ -49,38 +49,40 @@ async def post(self, role_name): servicenames = model.get('services', []) usernames = model.get('users', []) groupnames = model.pop("groups", []) - - services=[] + + services = [] for name in servicenames: existing = orm.Service.find(self.db, name=name) - #Non existing groups are ignored and won't be created + # Non existing groups are ignored and won't be created if existing is not None: services.append(existing) - users=[] + users = [] for name in usernames: existing = orm.User.find(self.db, name=name) - #Non existing groups are ignored and won't be created + # Non existing groups are ignored and won't be created if existing is not None: users.append(existing) - - groups=[] + groups = [] for name in groupnames: existing = orm.Group.find(self.db, name=name) - #Non existing groups are ignored and won't be created + # 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) + 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""" @@ -98,26 +100,26 @@ def put(self, role_name): servicenames = model.get('services', []) usernames = model.get('users', []) groupnames = model.pop("groups", []) - - services=[] + + services = [] for name in servicenames: existing = orm.Service.find(self.db, name=name) - #Non existing groups are ignored and won't be created + # Non existing groups are ignored and won't be created if existing is not None: services.append(existing) - users=[] + users = [] for name in usernames: existing = orm.User.find(self.db, name=name) - #Non existing groups are ignored and won't be created + # Non existing groups are ignored and won't be created if existing is not None: users.append(existing) - groups=[] + groups = [] for name in groupnames: existing = orm.Group.find(self.db, name=name) - #Non existing groups are ignored and won't be created + # 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) @@ -130,6 +132,7 @@ def put(self, role_name): 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""" @@ -140,7 +143,6 @@ def delete(self, role_name): self.set_status(204) - default_handlers = [ (r"/api/roles/([^/]+)", RoleAPIHandler), ] From a058b19c1450dac4c1cc7eb61d463194d7a78ad0 Mon Sep 17 00:00:00 2001 From: vpopescu Date: Tue, 19 Jul 2022 15:40:02 +0200 Subject: [PATCH 05/13] Added scope check when creating or updating roles --- jupyterhub/apihandlers/roles.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/jupyterhub/apihandlers/roles.py b/jupyterhub/apihandlers/roles.py index 79002f5ce3..30ce39d21e 100644 --- a/jupyterhub/apihandlers/roles.py +++ b/jupyterhub/apihandlers/roles.py @@ -49,6 +49,14 @@ async def post(self, role_name): 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: @@ -100,7 +108,14 @@ def put(self, role_name): 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) From 877ec354eea15e414427a95d4a3c864e3520acfc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 Jul 2022 13:40:39 +0000 Subject: [PATCH 06/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyterhub/apihandlers/roles.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/jupyterhub/apihandlers/roles.py b/jupyterhub/apihandlers/roles.py index 30ce39d21e..4df951d37c 100644 --- a/jupyterhub/apihandlers/roles.py +++ b/jupyterhub/apihandlers/roles.py @@ -49,10 +49,11 @@ async def post(self, role_name): servicenames = model.get('services', []) usernames = model.get('users', []) groupnames = model.pop("groups", []) - #check if scopes exist + # 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: @@ -108,10 +109,11 @@ def put(self, role_name): servicenames = model.get('services', []) usernames = model.get('users', []) groupnames = model.pop("groups", []) - #check if scopes exist + # 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: From ca0ca58d9abb7afd88f8145e0e9f611e89234c72 Mon Sep 17 00:00:00 2001 From: vpopescu Date: Thu, 28 Jul 2022 15:42:03 +0200 Subject: [PATCH 07/13] Added role api test functions in test_api --- jupyterhub/tests/test_api.py | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index fa96b22d0f..4717c2d919 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1883,6 +1883,116 @@ 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 # ----------------- From 5ebd495f0cf518f789b2e8fda5416d75e3578747 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 28 Jul 2022 13:42:38 +0000 Subject: [PATCH 08/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyterhub/tests/test_api.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 4717c2d919..525ec8b9b7 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1887,6 +1887,7 @@ async def test_auth_managed_groups(request, app, group, user): # Role API tests # ----------------- + @mark.role async def test_role_get(app): role = orm.Role(name='alphaflight') @@ -1947,18 +1948,15 @@ async def test_role_create_delete(app): 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 - + # correct scopes - #update scopes of role by adding correct scopes + # update scopes of role by adding correct scopes scopes = ['admin:groups', 'read:users'] r = await api_request( app, @@ -1970,7 +1968,7 @@ async def test_role_edit(app): 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 + # update scopes of role by adding wrong scopes scopes_wrong = ['read12:groups', 'reads:wrongscope'] r = await api_request( app, @@ -1978,9 +1976,9 @@ async def test_role_edit(app): method='put', data=json.dumps({'scopes': scopes_wrong}), ) - assert r.status_code== 409 + assert r.status_code == 409 - #removing scopes with put request + # removing scopes with put request r = await api_request( app, @@ -1990,7 +1988,6 @@ async def test_role_edit(app): ) r.raise_for_status() assert role.scopes == [] - # ----------------- From cacce81b430bbb3c9154790ccdd9fd09a84253a1 Mon Sep 17 00:00:00 2001 From: Vlad Vifor <58890859+vladfreeze@users.noreply.github.com> Date: Wed, 3 Aug 2022 17:17:37 +0200 Subject: [PATCH 09/13] Updated base.py --- jupyterhub/apihandlers/base.py | 78 ++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 3697e75eed..89f22aa2f3 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -187,22 +187,44 @@ def write_error(self, status_code, **kwargs): json.dumps({'status': status_code, 'message': message or status_message}) ) - def server_model(self, spawner): + def server_model(self, spawner, *, user=None): """Get the JSON model for a Spawner - Assume server permission already granted""" + Assume server permission already granted + """ + if isinstance(spawner, orm.Spawner): + # if an orm.Spawner is passed, + # create a model for a stopped Spawner + # not all info is available without the higher-level Spawner wrapper + orm_spawner = spawner + pending = None + ready = False + stopped = True + user = user + if user is None: + raise RuntimeError("Must specify User with orm.Spawner") + state = orm_spawner.state + else: + orm_spawner = spawner.orm_spawner + pending = spawner.pending + ready = spawner.ready + user = spawner.user + stopped = not spawner.active + state = spawner.get_state() + model = { - 'name': spawner.name, - 'last_activity': isoformat(spawner.orm_spawner.last_activity), - 'started': isoformat(spawner.orm_spawner.started), - 'pending': spawner.pending, - 'ready': spawner.ready, - 'url': url_path_join(spawner.user.url, url_escape_path(spawner.name), '/'), + 'name': orm_spawner.name, + 'last_activity': isoformat(orm_spawner.last_activity), + 'started': isoformat(orm_spawner.started), + 'pending': pending, + 'ready': ready, + 'stopped': stopped, + 'url': url_path_join(user.url, url_escape_path(spawner.name), '/'), 'user_options': spawner.user_options, - 'progress_url': spawner._progress_url, + 'progress_url': user.progress_url(spawner.name), } scope_filter = self.get_scope_filter('admin:server_state') if scope_filter(spawner, kind='server'): - model['state'] = spawner.get_state() + model['state'] = state return model def token_model(self, token): @@ -248,10 +270,22 @@ def _filter_model(self, model, access_map, entity, kind, keys=None): keys.update(allowed_keys) return model + _include_stopped_servers = None + + @property + def include_stopped_servers(self): + """Whether stopped servers should be included in user models""" + if self._include_stopped_servers is None: + self._include_stopped_servers = self.get_argument( + "include_stopped_servers", "0" + ).lower() not in {"0", "false"} + return self._include_stopped_servers + def user_model(self, user): """Get the JSON model for a User object""" if isinstance(user, orm.User): user = self.users[user.id] + include_stopped_servers = self.include_stopped_servers model = { 'kind': 'user', 'name': user.name, @@ -291,18 +325,29 @@ def user_model(self, user): if '' in user.spawners and 'pending' in allowed_keys: model['pending'] = user.spawners[''].pending - servers = model['servers'] = {} + servers = {} scope_filter = self.get_scope_filter('read:servers') for name, spawner in user.spawners.items(): # include 'active' servers, not just ready # (this includes pending events) - if spawner.active and scope_filter(spawner, kind='server'): + if (spawner.active or include_stopped_servers) and scope_filter( + spawner, kind='server' + ): servers[name] = self.server_model(spawner) - if not servers and 'servers' not in allowed_keys: + + if include_stopped_servers: + # add any stopped servers in the db + seen = set(servers.keys()) + for name, orm_spawner in user.orm_spawners.items(): + if name not in seen and scope_filter(orm_spawner, kind='server'): + servers[name] = self.server_model(orm_spawner, user=user) + + if "servers" in allowed_keys or servers: # omit servers if no access # leave present and empty # if request has access to read servers in general - model.pop('servers') + model["servers"] = servers + return model def group_model(self, group): @@ -381,12 +426,10 @@ def service_model(self, service): _role_model_types = { 'name': str, 'users': list, - 'roles': list, 'groups': list, 'scopes': list, 'services': list, } - def _check_model(self, model, model_types, name): """Check a model provided by a REST API request @@ -424,7 +467,6 @@ def _check_group_model(self, model): raise web.HTTPError( 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') @@ -433,7 +475,7 @@ def _check_role_model(self, model): 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"] From f38a7fe455063a94bf5c799c504f15764c790416 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Aug 2022 15:18:04 +0000 Subject: [PATCH 10/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyterhub/apihandlers/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 89f22aa2f3..9d819ef3f8 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -430,6 +430,7 @@ def service_model(self, service): 'scopes': list, 'services': list, } + def _check_model(self, model, model_types, name): """Check a model provided by a REST API request @@ -467,6 +468,7 @@ def _check_group_model(self, model): raise web.HTTPError( 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') @@ -475,7 +477,7 @@ def _check_role_model(self, model): 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"] From f18cfb6bd1adf51b92d6c14e98161346332c1f82 Mon Sep 17 00:00:00 2001 From: Vlad Vifor <58890859+vladfreeze@users.noreply.github.com> Date: Wed, 3 Aug 2022 17:33:35 +0200 Subject: [PATCH 11/13] Update base.py --- jupyterhub/apihandlers/base.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 9d819ef3f8..89f22aa2f3 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -430,7 +430,6 @@ def service_model(self, service): 'scopes': list, 'services': list, } - def _check_model(self, model, model_types, name): """Check a model provided by a REST API request @@ -468,7 +467,6 @@ def _check_group_model(self, model): raise web.HTTPError( 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') @@ -477,7 +475,7 @@ def _check_role_model(self, model): 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"] From 5f59e52469034ecbec4432e11d240636726f1313 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Aug 2022 15:34:04 +0000 Subject: [PATCH 12/13] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- jupyterhub/apihandlers/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 89f22aa2f3..9d819ef3f8 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -430,6 +430,7 @@ def service_model(self, service): 'scopes': list, 'services': list, } + def _check_model(self, model, model_types, name): """Check a model provided by a REST API request @@ -467,6 +468,7 @@ def _check_group_model(self, model): raise web.HTTPError( 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') @@ -475,7 +477,7 @@ def _check_role_model(self, model): 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"] From 3804865170607475593f3cceca45bb876ed5025a Mon Sep 17 00:00:00 2001 From: vpopescu Date: Thu, 4 Aug 2022 15:05:20 +0200 Subject: [PATCH 13/13] changed ordering in mapping --- jupyterhub/apihandlers/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index 3697e75eed..07e21ff284 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -332,7 +332,7 @@ def role_model(self, role): 'services': [s.name for s in role.services], } access_map = { - 'read:roles': {'kind', 'name', 'users', 'groups', 'scopes', 'services'}, + 'read:roles': {'kind', 'name', 'users', 'scopes', 'groups', 'services'}, } model = self._filter_model(model, access_map, role, 'role') return model