mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-19 16:03:00 +00:00
Implemented scope-based access in API handlers
This commit is contained in:
@@ -7,6 +7,7 @@ from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only
|
||||
from ..utils import needs_scope
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
@@ -34,13 +35,16 @@ class _GroupAPIHandler(APIHandler):
|
||||
|
||||
|
||||
class GroupListAPIHandler(_GroupAPIHandler):
|
||||
@admin_only
|
||||
def get(self):
|
||||
@needs_scope('read:groups')
|
||||
def get(self, subset=None):
|
||||
"""List groups"""
|
||||
data = [self.group_model(g) for g in self.db.query(orm.Group)]
|
||||
groups = self.db.query(orm.Group)
|
||||
if subset is not None:
|
||||
groups = groups.filter(orm.Group.name.in_(subset))
|
||||
data = [self.group_model(g) for g in groups]
|
||||
self.write(json.dumps(data))
|
||||
|
||||
@admin_only
|
||||
@needs_scope('admin:groups')
|
||||
async def post(self):
|
||||
"""POST creates Multiple groups """
|
||||
model = self.get_json_body()
|
||||
@@ -73,12 +77,14 @@ class GroupListAPIHandler(_GroupAPIHandler):
|
||||
class GroupAPIHandler(_GroupAPIHandler):
|
||||
"""View and modify groups by name"""
|
||||
|
||||
@admin_only
|
||||
def get(self, name):
|
||||
@needs_scope('read:groups')
|
||||
def get(self, name, subset=None):
|
||||
if subset is not None and name not in subset:
|
||||
raise web.HTTPError(403, "No read access to group {}".format(name))
|
||||
group = self.find_group(name)
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
@admin_only
|
||||
@needs_scope('admin:groups')
|
||||
async def post(self, name):
|
||||
"""POST creates a group by name"""
|
||||
model = self.get_json_body()
|
||||
@@ -104,9 +110,11 @@ class GroupAPIHandler(_GroupAPIHandler):
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
self.set_status(201)
|
||||
|
||||
@admin_only
|
||||
def delete(self, name):
|
||||
@needs_scope('admin:groups')
|
||||
def delete(self, name, subset=None):
|
||||
"""Delete a group by name"""
|
||||
if subset is not None and name not in subset:
|
||||
raise web.HTTPError(403, "No write access to group {}".format(name))
|
||||
group = self.find_group(name)
|
||||
self.log.info("Deleting group %s", name)
|
||||
self.db.delete(group)
|
||||
@@ -117,9 +125,11 @@ class GroupAPIHandler(_GroupAPIHandler):
|
||||
class GroupUsersAPIHandler(_GroupAPIHandler):
|
||||
"""Modify a group's user list"""
|
||||
|
||||
@admin_only
|
||||
def post(self, name):
|
||||
@needs_scope('groups')
|
||||
def post(self, name, subset=None):
|
||||
"""POST adds users to a group"""
|
||||
if subset is not None and name not in subset:
|
||||
raise web.HTTPError(403, "No access to add users to group {}".format(name))
|
||||
group = self.find_group(name)
|
||||
data = self.get_json_body()
|
||||
self._check_group_model(data)
|
||||
@@ -135,9 +145,11 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.group_model(group)))
|
||||
|
||||
@admin_only
|
||||
async def delete(self, name):
|
||||
@needs_scope('groups')
|
||||
async def delete(self, name, subset=None):
|
||||
"""DELETE removes users from a group"""
|
||||
if subset is not None and name not in subset:
|
||||
raise web.HTTPError(403, "No access to add users to group {}".format(name))
|
||||
group = self.find_group(name)
|
||||
data = self.get_json_body()
|
||||
self._check_group_model(data)
|
||||
|
@@ -9,11 +9,12 @@ from tornado.ioloop import IOLoop
|
||||
|
||||
from .._version import __version__
|
||||
from ..utils import admin_only
|
||||
from ..utils import needs_scope
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
class ShutdownAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
@needs_scope('shutdown')
|
||||
def post(self):
|
||||
"""POST /api/shutdown triggers a clean shutdown
|
||||
|
||||
@@ -65,7 +66,7 @@ class RootAPIHandler(APIHandler):
|
||||
|
||||
|
||||
class InfoAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
@needs_scope('admin') # Todo: Probably too strict
|
||||
def get(self):
|
||||
"""GET /api/info returns detailed info about the Hub and its API.
|
||||
|
||||
|
@@ -6,11 +6,12 @@ import json
|
||||
from tornado import web
|
||||
|
||||
from ..utils import admin_only
|
||||
from ..utils import needs_scope
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
class ProxyAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
@needs_scope('proxy')
|
||||
async def get(self):
|
||||
"""GET /api/proxy fetches the routing table
|
||||
|
||||
@@ -20,7 +21,7 @@ class ProxyAPIHandler(APIHandler):
|
||||
routes = await self.proxy.get_all_routes()
|
||||
self.write(json.dumps(routes))
|
||||
|
||||
@admin_only
|
||||
@needs_scope('proxy')
|
||||
async def post(self):
|
||||
"""POST checks the proxy to ensure that it's up to date.
|
||||
|
||||
@@ -29,7 +30,7 @@ class ProxyAPIHandler(APIHandler):
|
||||
"""
|
||||
await self.proxy.check_routes(self.users, self.services)
|
||||
|
||||
@admin_only
|
||||
@needs_scope('proxy')
|
||||
async def patch(self):
|
||||
"""PATCH updates the location of the proxy
|
||||
|
||||
|
@@ -10,6 +10,7 @@ from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only
|
||||
from ..utils import needs_scope
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
@@ -28,7 +29,7 @@ def service_model(service):
|
||||
|
||||
|
||||
class ServiceListAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
@needs_scope('read:services')
|
||||
def get(self):
|
||||
data = {name: service_model(service) for name, service in self.services.items()}
|
||||
self.write(json.dumps(data))
|
||||
@@ -56,7 +57,7 @@ def admin_or_self(method):
|
||||
|
||||
|
||||
class ServiceAPIHandler(APIHandler):
|
||||
@admin_or_self
|
||||
@needs_scope('read:services')
|
||||
def get(self, name):
|
||||
service = self.services[name]
|
||||
self.write(json.dumps(service_model(service)))
|
||||
|
@@ -14,10 +14,10 @@ from tornado.iostream import StreamClosedError
|
||||
|
||||
from .. import orm
|
||||
from ..user import User
|
||||
from ..utils import admin_only
|
||||
from ..utils import isoformat
|
||||
from ..utils import iterate_until
|
||||
from ..utils import maybe_future
|
||||
from ..utils import needs_scope
|
||||
from ..utils import url_path_join
|
||||
from .base import APIHandler
|
||||
|
||||
@@ -28,6 +28,7 @@ class SelfAPIHandler(APIHandler):
|
||||
Based on the authentication info. Acts as a 'whoami' for auth tokens.
|
||||
"""
|
||||
|
||||
@needs_scope('read:users')
|
||||
async def get(self):
|
||||
user = self.current_user
|
||||
if user is None:
|
||||
@@ -39,15 +40,19 @@ class SelfAPIHandler(APIHandler):
|
||||
|
||||
|
||||
class UserListAPIHandler(APIHandler):
|
||||
@admin_only
|
||||
def get(self):
|
||||
@needs_scope('read:users')
|
||||
def get(self, subset=None):
|
||||
users = self.db.query(orm.User)
|
||||
if subset is not None:
|
||||
users = users.filter(
|
||||
User.name.in_(subset)
|
||||
) # Should result in only one db query
|
||||
data = [
|
||||
self.user_model(u, include_servers=True, include_state=True)
|
||||
for u in self.db.query(orm.User)
|
||||
self.user_model(u, include_servers=True, include_state=True) for u in users
|
||||
]
|
||||
self.write(json.dumps(data))
|
||||
|
||||
@admin_only
|
||||
@needs_scope('users')
|
||||
async def post(self):
|
||||
data = self.get_json_body()
|
||||
if not data or not isinstance(data, dict) or not data.get('usernames'):
|
||||
@@ -122,9 +127,13 @@ def admin_or_self(method):
|
||||
|
||||
|
||||
class UserAPIHandler(APIHandler):
|
||||
@admin_or_self
|
||||
async def get(self, name):
|
||||
@needs_scope('read:users')
|
||||
async def get(self, name, subset=None):
|
||||
user = self.find_user(name)
|
||||
if subset is not None:
|
||||
if user not in subset:
|
||||
raise web.HTTPError(403, "No access to users")
|
||||
|
||||
model = self.user_model(
|
||||
user, include_servers=True, include_state=self.current_user.admin
|
||||
)
|
||||
@@ -137,7 +146,7 @@ class UserAPIHandler(APIHandler):
|
||||
model['auth_state'] = await user.get_auth_state()
|
||||
self.write(json.dumps(model))
|
||||
|
||||
@admin_only
|
||||
@needs_scope('admin:users')
|
||||
async def post(self, name):
|
||||
data = self.get_json_body()
|
||||
user = self.find_user(name)
|
||||
@@ -162,7 +171,7 @@ class UserAPIHandler(APIHandler):
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
self.set_status(201)
|
||||
|
||||
@admin_only
|
||||
@needs_scope('admin:users')
|
||||
async def delete(self, name):
|
||||
user = self.find_user(name)
|
||||
if user is None:
|
||||
@@ -187,7 +196,7 @@ class UserAPIHandler(APIHandler):
|
||||
|
||||
self.set_status(204)
|
||||
|
||||
@admin_only
|
||||
@needs_scope('admin:users')
|
||||
async def patch(self, name):
|
||||
user = self.find_user(name)
|
||||
if user is None:
|
||||
@@ -215,7 +224,7 @@ class UserAPIHandler(APIHandler):
|
||||
class UserTokenListAPIHandler(APIHandler):
|
||||
"""API endpoint for listing/creating tokens"""
|
||||
|
||||
@admin_or_self
|
||||
@needs_scope('users:tokens')
|
||||
def get(self, name):
|
||||
"""Get tokens for a given user"""
|
||||
user = self.find_user(name)
|
||||
@@ -249,6 +258,7 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
oauth_tokens.append(self.token_model(token))
|
||||
self.write(json.dumps({'api_tokens': api_tokens, 'oauth_tokens': oauth_tokens}))
|
||||
|
||||
@needs_scope('users:tokens')
|
||||
async def post(self, name):
|
||||
body = self.get_json_body() or {}
|
||||
if not isinstance(body, dict):
|
||||
@@ -313,6 +323,7 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
class UserTokenAPIHandler(APIHandler):
|
||||
"""API endpoint for retrieving/deleting individual tokens"""
|
||||
|
||||
@needs_scope('read:users:tokens')
|
||||
def find_token_by_id(self, user, token_id):
|
||||
"""Find a token object by token-id key
|
||||
|
||||
@@ -320,7 +331,7 @@ class UserTokenAPIHandler(APIHandler):
|
||||
(e.g. wrong owner, invalid key format, etc.)
|
||||
"""
|
||||
not_found = "No such token %s for user %s" % (token_id, user.name)
|
||||
prefix, id = token_id[0], token_id[1:]
|
||||
prefix, id_ = token_id[0], token_id[1:]
|
||||
if prefix == 'a':
|
||||
Token = orm.APIToken
|
||||
elif prefix == 'o':
|
||||
@@ -328,16 +339,16 @@ class UserTokenAPIHandler(APIHandler):
|
||||
else:
|
||||
raise web.HTTPError(404, not_found)
|
||||
try:
|
||||
id = int(id)
|
||||
id_ = int(id_)
|
||||
except ValueError:
|
||||
raise web.HTTPError(404, not_found)
|
||||
|
||||
orm_token = self.db.query(Token).filter(Token.id == id).first()
|
||||
orm_token = self.db.query(Token).filter(Token.id == id_).first()
|
||||
if orm_token is None or orm_token.user is not user.orm_user:
|
||||
raise web.HTTPError(404, "Token not found %s", orm_token)
|
||||
return orm_token
|
||||
|
||||
@admin_or_self
|
||||
@needs_scope('read:users:tokens')
|
||||
def get(self, name, token_id):
|
||||
""""""
|
||||
user = self.find_user(name)
|
||||
@@ -346,7 +357,7 @@ class UserTokenAPIHandler(APIHandler):
|
||||
token = self.find_token_by_id(user, token_id)
|
||||
self.write(json.dumps(self.token_model(token)))
|
||||
|
||||
@admin_or_self
|
||||
@needs_scope('users:tokens')
|
||||
def delete(self, name, token_id):
|
||||
"""Delete a token"""
|
||||
user = self.find_user(name)
|
||||
@@ -371,12 +382,17 @@ class UserTokenAPIHandler(APIHandler):
|
||||
class UserServerAPIHandler(APIHandler):
|
||||
"""Start and stop single-user servers"""
|
||||
|
||||
@admin_or_self
|
||||
async def post(self, name, server_name=''):
|
||||
@needs_scope('user:servers')
|
||||
async def post(self, name, server_name='', subset=None):
|
||||
user = self.find_user(name)
|
||||
if server_name:
|
||||
if not self.allow_named_servers:
|
||||
raise web.HTTPError(400, "Named servers are not enabled.")
|
||||
if subset is not None:
|
||||
if server_name not in subset:
|
||||
raise web.HTTPError(
|
||||
403, "No access to create {}".format(server_name)
|
||||
)
|
||||
if (
|
||||
self.named_server_limit_per_user > 0
|
||||
and server_name not in user.orm_spawners
|
||||
@@ -416,7 +432,7 @@ class UserServerAPIHandler(APIHandler):
|
||||
self.set_header('Content-Type', 'text/plain')
|
||||
self.set_status(status)
|
||||
|
||||
@admin_or_self
|
||||
@needs_scope('user:servers')
|
||||
async def delete(self, name, server_name=''):
|
||||
user = self.find_user(name)
|
||||
options = self.get_json_body()
|
||||
@@ -479,7 +495,7 @@ class UserAdminAccessAPIHandler(APIHandler):
|
||||
This handler sets the necessary cookie for an admin to login to a single-user server.
|
||||
"""
|
||||
|
||||
@admin_only
|
||||
@needs_scope('users:servers')
|
||||
def post(self, name):
|
||||
self.log.warning(
|
||||
"Deprecated in JupyterHub 0.8."
|
||||
@@ -535,11 +551,16 @@ class SpawnProgressAPIHandler(APIHandler):
|
||||
|
||||
await asyncio.wait([self._finish_future], timeout=self.keepalive_interval)
|
||||
|
||||
@admin_or_self
|
||||
async def get(self, username, server_name=''):
|
||||
@needs_scope('read:users:servers')
|
||||
async def get(self, username, server_name='', subset=None):
|
||||
self.set_header('Cache-Control', 'no-cache')
|
||||
if server_name is None:
|
||||
server_name = ''
|
||||
if subset is not None:
|
||||
if server_name not in subset:
|
||||
raise web.HTTPError(
|
||||
403, "No read access to server {}".format(server_name)
|
||||
)
|
||||
user = self.find_user(username)
|
||||
if user is None:
|
||||
# no such user
|
||||
@@ -678,7 +699,7 @@ class ActivityAPIHandler(APIHandler):
|
||||
)
|
||||
return servers
|
||||
|
||||
@admin_or_self
|
||||
@needs_scope('users')
|
||||
def post(self, username):
|
||||
user = self.find_user(username)
|
||||
if user is None:
|
||||
|
@@ -8,6 +8,7 @@ import hashlib
|
||||
import inspect
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
@@ -247,9 +248,10 @@ def auth_decorator(check_auth):
|
||||
|
||||
def decorator(method):
|
||||
def decorated(self, *args, **kwargs):
|
||||
check_auth(self)
|
||||
check_auth(self, **kwargs)
|
||||
return method(self, *args, **kwargs)
|
||||
|
||||
# Perhaps replace with functools.wrap
|
||||
decorated.__name__ = method.__name__
|
||||
decorated.__doc__ = method.__doc__
|
||||
return decorated
|
||||
@@ -296,6 +298,20 @@ def metrics_authentication(self):
|
||||
raise web.HTTPError(403)
|
||||
|
||||
|
||||
@auth_decorator
|
||||
def needs_scope(self, scope, **kwargs):
|
||||
"""Decorator to restrict access to users or services with the required scope"""
|
||||
if scope not in self.current_scopes:
|
||||
# Check if access is not restricted to user/server/group
|
||||
match_string = re.compile("^" + re.escape(scope) + r"!.+=.+$")
|
||||
subscopes = filter(lambda s: re.search(match_string, s), self.current_scopes)
|
||||
subset = [subscope.split('=')[1] for subscope in subscopes]
|
||||
if not subset:
|
||||
raise web.HTTPError(403, "Action is not authorized with current scopes")
|
||||
else:
|
||||
kwargs['subset'] = subset
|
||||
|
||||
|
||||
# Token utilities
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user