mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-15 14:03:02 +00:00
add groups API
This commit is contained in:
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
136
jupyterhub/apihandlers/groups.py
Normal file
136
jupyterhub/apihandlers/groups.py
Normal 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),
|
||||
]
|
@@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user