diff --git a/docs/rest-api.yml b/docs/rest-api.yml index 1dd95069..de2b8847 100644 --- a/docs/rest-api.yml +++ b/docs/rest-api.yml @@ -158,6 +158,103 @@ paths: responses: '200': description: Sets a cookie granting the requesting admin access to the user's server + /groups: + get: + summary: List groups + responses: + '200': + description: The list of groups + schema: + type: array + items: + $ref: '#/definitions/Group' + /groups/{name}: + get: + summary: Get a group by name + parameters: + - name: name + description: group name + in: path + required: true + type: string + responses: + '200': + description: The group model + schema: + $ref: '#/definitions/Group' + post: + summary: Create a group + parameters: + - name: name + description: group name + in: path + required: true + type: string + responses: + '201': + description: The group has been created + schema: + $ref: '#/definitions/Group' + delete: + summary: Delete a group + parameters: + - name: name + description: group name + in: path + required: true + type: string + responses: + '204': + description: The group has been deleted + /groups/{name}/users: + post: + summary: add users to a group + parameters: + - name: name + description: group name + in: path + required: true + type: string + - name: data + in: body + required: true + description: The users to add to the group + schema: + type: object + properties: + users: + type: array + description: List of usernames to add to the group + items: + type: string + responses: + '200': + description: The users have been added to the group + schema: + $ref: '#/definitions/Group' + delete: + summary: Remove users from a group + parameters: + - name: name + description: group name + in: path + required: true + type: string + - name: data + in: body + required: true + description: The users to add to the group + schema: + type: object + properties: + users: + type: array + description: List of usernames to add to the group + items: + type: string + responses: + '200': + description: The users have been removed from the group /proxy: get: summary: Get the proxy's routing table @@ -246,6 +343,11 @@ definitions: admin: type: boolean description: Whether the user is an admin + groups: + type: array + description: The names of groups of which this user is a member + items: + type: string server: type: string description: The user's server's base URL, if running; null if not. @@ -257,3 +359,14 @@ definitions: type: string format: ISO8601 Timestamp description: Timestamp of last-seen activity from the user + Group: + type: object + properties: + name: + type: string + description: The group's name + users: + type: array + description: The names of users who are members of this group + items: + type: string diff --git a/jupyterhub/apihandlers/__init__.py b/jupyterhub/apihandlers/__init__.py index ec07df9b..5ded7b7e 100644 --- a/jupyterhub/apihandlers/__init__.py +++ b/jupyterhub/apihandlers/__init__.py @@ -3,9 +3,9 @@ from .auth import * from .hub import * from .proxy import * from .users import * - +from .groups import * from . import auth, hub, proxy, users default_handlers = [] -for mod in (auth, hub, proxy, users): +for mod in (auth, hub, proxy, users, groups): default_handlers.extend(mod.default_handlers) diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index cb987c7e..da5a2ade 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -87,6 +87,7 @@ class APIHandler(BaseHandler): })) def user_model(self, user): + """Get the JSON model for a User object""" model = { 'name': user.name, 'admin': user.admin, @@ -100,23 +101,57 @@ class APIHandler(BaseHandler): elif user.stop_pending: model['pending'] = 'stop' return model - - _model_types = { + + def group_model(self, group): + """Get the JSON model for a Group object""" + return { + 'name': group.name, + 'users': [ u.name for u in group.users ] + } + + _user_model_types = { 'name': str, 'admin': bool, + 'groups': list, } - - def _check_user_model(self, model): + + _group_model_types = { + 'name': str, + 'users': list, + } + + def _check_model(self, model, model_types, name): + """Check a model provided by a REST API request + + Args: + model (dict): user-provided model + model_types (dict): dict of key:type used to validate types and keys + name (str): name of the model, used in error messages + """ if not isinstance(model, dict): raise web.HTTPError(400, "Invalid JSON data: %r" % model) - if not set(model).issubset(set(self._model_types)): + if not set(model).issubset(set(model_types)): raise web.HTTPError(400, "Invalid JSON keys: %r" % model) for key, value in model.items(): - if not isinstance(value, self._model_types[key]): - raise web.HTTPError(400, "user.%s must be %s, not: %r" % ( - key, self._model_types[key], type(value) + if not isinstance(value, model_types[key]): + raise web.HTTPError(400, "%s.%s must be %s, not: %r" % ( + name, key, model_types[key], type(value) )) + def _check_user_model(self, model): + """Check a request-provided user model from a REST API""" + return self._check_model(model, self._user_model_types, 'user') + for groupname in model.get('groups', []): + if not isinstance(groupname, str): + raise web.HTTPError(400, "group names must be str, not %r" % type(groupname)) + + def _check_group_model(self, model): + """Check a request-provided user model from a REST API""" + self._check_model(model, self._group_model_types, 'group') + for username in model.get('users', []): + if not isinstance(username, str): + raise web.HTTPError(400, "usernames must be str, not %r" % type(groupname)) + def options(self, *args, **kwargs): self.set_header('Access-Control-Allow-Headers', 'accept, content-type') self.finish() diff --git a/jupyterhub/apihandlers/groups.py b/jupyterhub/apihandlers/groups.py new file mode 100644 index 00000000..e30c6747 --- /dev/null +++ b/jupyterhub/apihandlers/groups.py @@ -0,0 +1,136 @@ +"""Group handlers""" + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +import json + +from tornado import gen, web + +from .. import orm +from ..utils import admin_only +from .base import APIHandler + + +class _GroupAPIHandler(APIHandler): + def _usernames_to_users(self, usernames): + """Turn a list of usernames into user objects""" + users = [] + for username in usernames: + username = self.authenticator.normalize_username(username) + user = self.find_user(username) + if user is None: + raise web.HTTPError(400, "No such user: %s" % username) + users.append(user.orm_user) + return users + + def find_group(self, name): + """Find and return a group by name. + + Raise 404 if not found. + """ + group = orm.Group.find(self.db, name=name) + if group is None: + raise web.HTTPError(404, "No such group: %s", name) + return group + +class GroupListAPIHandler(_GroupAPIHandler): + @admin_only + def get(self): + """List groups""" + data = [ self.group_model(g) for g in self.db.query(orm.Group) ] + self.write(json.dumps(data)) + + +class GroupAPIHandler(_GroupAPIHandler): + """View and modify groups by name""" + + @admin_only + def get(self, name): + group = self.find_group(name) + self.write(json.dumps(self.group_model(group))) + + @admin_only + @gen.coroutine + def post(self, name): + """POST creates a group by name""" + model = self.get_json_body() + if model is None: + model = {} + else: + self._check_group_model(model) + + existing = orm.Group.find(self.db, name=name) + if existing is not None: + raise web.HTTPError(400, "Group %s already exists" % name) + + usernames = model.get('users', []) + # check that users exist + users = self._usernames_to_users(usernames) + + # create the group + self.log.info("Creating new group %s with %i users", + name, len(users), + ) + self.log.debug("Users: %s", usernames) + group = orm.Group(name=name, users=users) + self.db.add(group) + self.db.commit() + self.write(json.dumps(self.group_model(group))) + self.set_status(201) + + @admin_only + def delete(self, name): + """Delete a group by name""" + group = self.find_group(name) + self.log.info("Deleting group %s", name) + self.db.delete(group) + self.db.commit() + self.set_status(204) + + +class GroupUsersAPIHandler(_GroupAPIHandler): + """Modify a group's user list""" + @admin_only + def post(self, name): + """POST adds users to a group""" + group = self.find_group(name) + data = self.get_json_body() + self._check_group_model(data) + if 'users' not in data: + raise web.HTTPError(400, "Must specify users to add") + self.log.info("Adding %i users to group %s", len(data['users']), name) + self.log.debug("Adding: %s", data['users']) + for user in self._usernames_to_users(data['users']): + if user not in group.users: + group.users.append(user) + else: + self.log.warning("User %s already in group %s", user.name, name) + self.db.commit() + self.write(json.dumps(self.group_model(group))) + + @gen.coroutine + @admin_only + def delete(self, name): + """DELETE removes users from a group""" + group = self.find_group(name) + data = self.get_json_body() + self._check_group_model(data) + if 'users' not in data: + raise web.HTTPError(400, "Must specify users to delete") + self.log.info("Removing %i users from group %s", len(data['users']), name) + self.log.debug("Removing: %s", data['users']) + for user in self._usernames_to_users(data['users']): + if user in group.users: + group.users.remove(user) + else: + self.log.warning("User %s already not in group %s", user.name, name) + self.db.commit() + self.write(json.dumps(self.group_model(group))) + + +default_handlers = [ + (r"/api/groups", GroupListAPIHandler), + (r"/api/groups/([^/]+)", GroupAPIHandler), + (r"/api/groups/([^/]+)/users", GroupUsersAPIHandler), +] diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 0e2ebd36..118dad7d 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -146,6 +146,7 @@ def test_referer_check(app, io_loop): ) assert r.status_code == 200 +# user API tests def test_get_users(app): db = app.db @@ -295,6 +296,7 @@ def test_add_admin(app): assert user.name == name assert user.admin + def test_delete_user(app): db = app.db mal = add_user(db, name='mal') @@ -321,6 +323,7 @@ def test_make_admin(app): assert user.name == name assert user.admin + def get_app_user(app, name): """Get the User object from the main thread @@ -335,6 +338,7 @@ def get_app_user(app, name): user_id = q.get(timeout=2) return app.users[user_id] + def test_spawn(app, io_loop): db = app.db name = 'wash' @@ -375,6 +379,7 @@ def test_spawn(app, io_loop): status = io_loop.run_sync(app_user.spawner.poll) assert status == 0 + def test_slow_spawn(app, io_loop): # app.tornado_application.settings['spawner_class'] = mocking.SlowSpawner app.tornado_settings['spawner_class'] = mocking.SlowSpawner @@ -484,6 +489,7 @@ def test_cookie(app): reply = r.json() assert reply['name'] == name + def test_token(app): name = 'book' user = add_user(app.db, app=app, name=name) @@ -495,6 +501,7 @@ def test_token(app): r = api_request(app, 'authorizations/token', 'notauthorized') assert r.status_code == 404 + def test_get_token(app): name = 'user' user = add_user(app.db, app=app, name=name) @@ -507,6 +514,7 @@ def test_get_token(app): token = json.loads(data) assert not token['Authentication'] is None + def test_bad_get_token(app): name = 'user' password = 'fake' @@ -517,6 +525,127 @@ def test_bad_get_token(app): })) assert r.status_code == 403 +# group API tests + +def test_groups_list(app): + r = api_request(app, 'groups') + r.raise_for_status() + reply = r.json() + assert reply == [] + + # create a group + group = orm.Group(name='alphaflight') + app.db.add(group) + app.db.commit() + + r = api_request(app, 'groups') + r.raise_for_status() + reply = r.json() + assert reply == [{ + 'name': 'alphaflight', + 'users': [] + }] + + +def test_group_get(app): + group = orm.Group.find(app.db, name='alphaflight') + user = add_user(app.db, app=app, name='sasquatch') + group.users.append(user) + app.db.commit() + + r = api_request(app, 'groups/runaways') + assert r.status_code == 404 + + r = api_request(app, 'groups/alphaflight') + r.raise_for_status() + reply = r.json() + assert reply == { + 'name': 'alphaflight', + 'users': ['sasquatch'] + } + + +def test_group_create_delete(app): + db = app.db + r = api_request(app, 'groups/runaways', method='delete') + assert r.status_code == 404 + + r = api_request(app, 'groups/new', method='post', data=json.dumps({ + 'users': ['doesntexist'] + })) + assert r.status_code == 400 + assert orm.Group.find(db, name='new') is None + + r = api_request(app, 'groups/omegaflight', method='post', data=json.dumps({ + 'users': ['sasquatch'] + })) + r.raise_for_status() + + omegaflight = orm.Group.find(db, name='omegaflight') + sasquatch = find_user(db, name='sasquatch') + assert omegaflight in sasquatch.groups + assert sasquatch in omegaflight.users + + # create duplicate raises 400 + r = api_request(app, 'groups/omegaflight', method='post') + assert r.status_code == 400 + + r = api_request(app, 'groups/omegaflight', method='delete') + assert r.status_code == 204 + assert omegaflight not in sasquatch.groups + assert orm.Group.find(db, name='omegaflight') is None + + # delete nonexistant gives 404 + r = api_request(app, 'groups/omegaflight', method='delete') + assert r.status_code == 404 + + + +def test_group_add_users(app): + db = app.db + # must specify users + r = api_request(app, 'groups/alphaflight/users', method='post', data='{}') + assert r.status_code == 400 + + names = ['aurora', 'guardian', 'northstar', 'sasquatch', 'shaman', 'snowbird'] + users = [ find_user(db, name=name) or add_user(db, app=app, name=name) for name in names ] + r = api_request(app, 'groups/alphaflight/users', method='post', data=json.dumps({ + 'users': names, + })) + r.raise_for_status() + + for user in users: + print(user.name) + assert [ g.name for g in user.groups ] == ['alphaflight'] + + group = orm.Group.find(db, name='alphaflight') + assert sorted([ u.name for u in group.users ]) == sorted(names) + + +def test_group_delete_users(app): + db = app.db + # must specify users + r = api_request(app, 'groups/alphaflight/users', method='delete', data='{}') + assert r.status_code == 400 + + names = ['aurora', 'guardian', 'northstar', 'sasquatch', 'shaman', 'snowbird'] + users = [ find_user(db, name=name) for name in names ] + r = api_request(app, 'groups/alphaflight/users', method='delete', data=json.dumps({ + 'users': names[:2], + })) + r.raise_for_status() + + for user in users[:2]: + assert user.groups == [] + for user in users[2:]: + assert [ g.name for g in user.groups ] == ['alphaflight'] + + group = orm.Group.find(db, name='alphaflight') + assert sorted([ u.name for u in group.users ]) == sorted(names[2:]) + + +# general API tests + def test_options(app): r = api_request(app, 'users', method='options') r.raise_for_status() @@ -528,6 +657,7 @@ def test_bad_json_body(app): assert r.status_code == 400 +# shutdown must be last def test_shutdown(app): r = api_request(app, 'shutdown', method='post', data=json.dumps({ 'servers': True, @@ -541,3 +671,4 @@ def test_shutdown(app): else: break assert not app.io_loop._running +