Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add role handlers and endpoints for REST API #3980

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions 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)
32 changes: 32 additions & 0 deletions jupyterhub/apihandlers/base.py
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
165 changes: 165 additions & 0 deletions 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),
]
107 changes: 107 additions & 0 deletions jupyterhub/tests/test_api.py
Expand Up @@ -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
# -----------------
Expand Down