add groups API

This commit is contained in:
Min RK
2016-06-01 13:56:13 +02:00
parent 71f47b7a70
commit 6d106b24f4
5 changed files with 425 additions and 10 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
@@ -101,22 +102,56 @@ class APIHandler(BaseHandler):
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()

View File

@@ -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),
]

View File

@@ -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