mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
implement access scopes
- access:services for services - access:users:servers for servers - tokens automatically have access to their issuing client (if their owner does, too) - Check access scope in HubAuth integration
This commit is contained in:
@@ -1,15 +1,33 @@
|
||||
# our user list
|
||||
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
|
||||
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
|
||||
|
||||
# ellisonbg and willingc have access to a shared server:
|
||||
|
||||
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']}
|
||||
c.JupyterHub.load_groups = {'shared-notebook-grp': ['ellisonbg', 'willingc']}
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "shared-notebook",
|
||||
"groups": ["shared-notebook-grp"],
|
||||
"scopes": ["access:services!service=shared-notebook"],
|
||||
},
|
||||
# by default, the user role has access to all services
|
||||
# we want to limit that, so give users only access to 'self'
|
||||
{
|
||||
"name": "user",
|
||||
"scopes": ["self"],
|
||||
},
|
||||
]
|
||||
|
||||
# start the notebook server as a service
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'shared-notebook',
|
||||
'url': 'http://127.0.0.1:9999',
|
||||
'api_token': 'super-secret',
|
||||
'api_token': 'c3a29e5d386fd7c9aa1e8fe9d41c282ec8b',
|
||||
}
|
||||
]
|
||||
|
||||
# dummy spawner and authenticator for testing, don't actually use these!
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
|
@@ -1,9 +1,11 @@
|
||||
#!/bin/bash -l
|
||||
set -e
|
||||
|
||||
export JUPYTERHUB_API_TOKEN=super-secret
|
||||
# these must match the values in jupyterhub_config.py
|
||||
export JUPYTERHUB_API_TOKEN=c3a29e5d386fd7c9aa1e8fe9d41c282ec8b
|
||||
export JUPYTERHUB_SERVICE_URL=http://127.0.0.1:9999
|
||||
export JUPYTERHUB_SERVICE_NAME=shared-notebook
|
||||
export JUPYTERHUB_SERVICE_PREFIX="/services/${JUPYTERHUB_SERVICE_NAME}/"
|
||||
export JUPYTERHUB_CLIENT_ID="service-${JUPYTERHUB_SERVICE_NAME}"
|
||||
|
||||
jupyterhub-singleuser \
|
||||
--group='shared'
|
||||
jupyterhub-singleuser
|
||||
|
@@ -216,6 +216,31 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
)
|
||||
credentials = self.add_credentials(credentials)
|
||||
client = self.oauth_provider.fetch_by_client_id(credentials['client_id'])
|
||||
allowed = False
|
||||
|
||||
# check for access to target resource
|
||||
if client.spawner:
|
||||
scope_filter = self.get_scope_filter("access:users:servers")
|
||||
allowed = scope_filter(client.spawner, kind='server')
|
||||
elif client.service:
|
||||
scope_filter = self.get_scope_filter("access:services")
|
||||
allowed = scope_filter(client.service, kind='service')
|
||||
else:
|
||||
# client is not associated with a service or spawner.
|
||||
# This shouldn't happen, but it might if this is a stale or forged request
|
||||
# from a service or spawner that's since been deleted
|
||||
self.log.error(
|
||||
f"OAuth client {client} has no service or spawner, cannot resolve scopes."
|
||||
)
|
||||
raise web.HTTPError(500, "OAuth configuration error")
|
||||
|
||||
if not allowed:
|
||||
self.log.error(
|
||||
f"User {self.current_user} not allowed to access {client.description}"
|
||||
)
|
||||
raise web.HTTPError(
|
||||
403, f"You do not have permission to access {client.description}"
|
||||
)
|
||||
if not self.needs_oauth_confirm(self.current_user, client):
|
||||
self.log.debug(
|
||||
"Skipping oauth confirmation for %s accessing %s",
|
||||
|
@@ -35,21 +35,31 @@ class SelfAPIHandler(APIHandler):
|
||||
user = self.current_user
|
||||
if user is None:
|
||||
raise web.HTTPError(403)
|
||||
|
||||
_added_scopes = set()
|
||||
if isinstance(user, orm.Service):
|
||||
# ensure we have the minimal 'identify' scopes for the token owner
|
||||
self.expanded_scopes.update(scopes.identify_scopes(user))
|
||||
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
||||
model = self.service_model(user)
|
||||
identify_scopes = scopes.identify_scopes(user)
|
||||
get_model = self.service_model
|
||||
else:
|
||||
self.expanded_scopes.update(scopes.identify_scopes(user.orm_user))
|
||||
print('Expanded scopes in selfapihandler')
|
||||
identify_scopes = scopes.identify_scopes(user.orm_user)
|
||||
get_model = self.user_model
|
||||
|
||||
# ensure we have permission to identify ourselves
|
||||
# all tokens can do this on this endpoint
|
||||
for scope in identify_scopes:
|
||||
if scope not in self.expanded_scopes:
|
||||
_added_scopes.add(scope)
|
||||
self.expanded_scopes.add(scope)
|
||||
if _added_scopes:
|
||||
# re-parse with new scopes
|
||||
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
||||
model = self.user_model(user)
|
||||
# validate return, should have at least kind and name,
|
||||
# otherwise our filters did something wrong
|
||||
for key in ("kind", "name"):
|
||||
if key not in model:
|
||||
raise ValueError(f"Missing identify model for {user}: {model}")
|
||||
|
||||
model = get_model(user)
|
||||
|
||||
# add scopes to identify model,
|
||||
# but not the scopes we added to ensure we could read our own model
|
||||
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
|
||||
self.write(json.dumps(model))
|
||||
|
||||
|
||||
|
@@ -24,11 +24,13 @@ def get_default_roles():
|
||||
{
|
||||
'name': 'user',
|
||||
'description': 'Standard user privileges',
|
||||
'scopes': ['self'],
|
||||
'scopes': [
|
||||
'self',
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'admin',
|
||||
'description': 'Admin privileges (currently can do everything)',
|
||||
'description': 'Admin privileges (can do everything)',
|
||||
'scopes': [
|
||||
'admin:users',
|
||||
'admin:users:servers',
|
||||
@@ -38,12 +40,17 @@ def get_default_roles():
|
||||
'read:hub',
|
||||
'proxy',
|
||||
'shutdown',
|
||||
'access:services',
|
||||
'access:users:servers',
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'server',
|
||||
'description': 'Post activity only',
|
||||
'scopes': ['users:activity!user'],
|
||||
'scopes': [
|
||||
'users:activity!user',
|
||||
'access:users:servers!user',
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'token',
|
||||
@@ -64,6 +71,7 @@ def expand_self_scope(name):
|
||||
users:activity
|
||||
users:servers
|
||||
users:tokens
|
||||
access:users:servers
|
||||
|
||||
Arguments:
|
||||
name (str): user name
|
||||
@@ -80,6 +88,8 @@ def expand_self_scope(name):
|
||||
'users:tokens',
|
||||
]
|
||||
read_scope_list = ['read:' + scope for scope in scope_list]
|
||||
# access doesn't want the 'read:' prefix
|
||||
scope_list.append('access:users:servers')
|
||||
scope_list.extend(read_scope_list)
|
||||
return {"{}!user={}".format(scope, name) for scope in scope_list}
|
||||
|
||||
|
@@ -97,6 +97,12 @@ scope_definitions = {
|
||||
'read:hub': {
|
||||
'description': 'Read-only access to detailed information about the Hub.'
|
||||
},
|
||||
'access:users:servers': {
|
||||
'description': 'Access user servers via API or browser.',
|
||||
},
|
||||
'access:services': {
|
||||
'description': 'Access services via API or browser.',
|
||||
},
|
||||
'proxy': {
|
||||
'description': 'Allows for obtaining information about the proxy’s routing table, for syncing the Hub with proxy and notifying the Hub about a new proxy.'
|
||||
},
|
||||
@@ -220,6 +226,23 @@ def get_scopes_for(orm_object):
|
||||
app_log.warning(f"Authenticated with token {orm_object}")
|
||||
owner = orm_object.user or orm_object.service
|
||||
token_scopes = roles.expand_roles_to_scopes(orm_object)
|
||||
if orm_object.client_id != "jupyterhub":
|
||||
# oauth tokens can be used to access the service issuing the token,
|
||||
# assuming the owner itself still has permission to do so
|
||||
spawner = orm_object.oauth_client.spawner
|
||||
if spawner:
|
||||
token_scopes.add(
|
||||
f"access:users:servers!server={spawner.user.name}/{spawner.name}"
|
||||
)
|
||||
else:
|
||||
service = orm_object.oauth_client.service
|
||||
if service:
|
||||
token_scopes.add(f"access:services!service={service.name}")
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Token {orm_object} has no associated service or spawner!"
|
||||
)
|
||||
|
||||
owner_scopes = roles.expand_roles_to_scopes(owner)
|
||||
if 'all' in token_scopes:
|
||||
token_scopes.remove('all')
|
||||
|
@@ -33,13 +33,50 @@ from traitlets import Dict
|
||||
from traitlets import Instance
|
||||
from traitlets import Integer
|
||||
from traitlets import observe
|
||||
from traitlets import Set
|
||||
from traitlets import Unicode
|
||||
from traitlets import validate
|
||||
from traitlets.config import SingletonConfigurable
|
||||
|
||||
from ..scopes import _intersect_scopes
|
||||
from ..utils import url_path_join
|
||||
|
||||
|
||||
def check_scopes(required_scopes, scopes):
|
||||
"""Check that required_scope(s) are in scopes
|
||||
|
||||
Returns the subset of scopes matching required_scopes,
|
||||
which is truthy if any scopes match any required scopes.
|
||||
|
||||
Correctly resolves scope filters *except* for groups -> user,
|
||||
e.g. require: access:server!user=x, have: access:server!group=y
|
||||
will not grant access to user x even if user x is in group y.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
required_scopes: set
|
||||
The set of scopes required.
|
||||
scopes: set
|
||||
The set (or list) of scopes to check against required_scopes
|
||||
|
||||
Returns
|
||||
-------
|
||||
relevant_scopes: set
|
||||
The set of scopes in required_scopes that are present in scopes,
|
||||
which is truthy if any required scopes are present,
|
||||
and falsy otherwise.
|
||||
"""
|
||||
if isinstance(required_scopes, str):
|
||||
required_scopes = {required_scopes}
|
||||
|
||||
intersection = _intersect_scopes(required_scopes, scopes)
|
||||
# re-intersect with required_scopes in case the intersection
|
||||
# applies stricter filters than required_scopes declares
|
||||
# e.g. required_scopes = {'read:users'} and intersection has only {'read:users!user=x'}
|
||||
return set(required_scopes) & intersection
|
||||
|
||||
|
||||
class _ExpiringDict(dict):
|
||||
"""Dict-like cache for Hub API requests
|
||||
|
||||
@@ -285,6 +322,24 @@ class HubAuth(SingletonConfigurable):
|
||||
def _default_cache(self):
|
||||
return _ExpiringDict(self.cache_max_age)
|
||||
|
||||
oauth_scopes = Set(
|
||||
Unicode(),
|
||||
help="""OAuth scopes to use for allowing access.
|
||||
|
||||
Get from $JUPYTERHUB_OAUTH_SCOPES by default.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@default('oauth_scopes')
|
||||
def _default_scopes(self):
|
||||
env_scopes = os.getenv('JUPYTERHUB_OAUTH_SCOPES')
|
||||
if env_scopes:
|
||||
return set(json.loads(env_scopes))
|
||||
service_name = os.getenv("JUPYTERHUB_SERVICE_NAME")
|
||||
if service_name:
|
||||
return {f'access:services!service={service_name}'}
|
||||
return set()
|
||||
|
||||
def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True):
|
||||
"""Identify a user with the Hub
|
||||
|
||||
@@ -495,6 +550,10 @@ class HubAuth(SingletonConfigurable):
|
||||
app_log.debug("No user identified")
|
||||
return user_model
|
||||
|
||||
def check_scopes(self, required_scopes, user):
|
||||
"""Check whether the user has required scope(s)"""
|
||||
return check_scopes(required_scopes, set(user["scopes"]))
|
||||
|
||||
|
||||
class HubOAuth(HubAuth):
|
||||
"""HubAuth using OAuth for login instead of cookies set by the Hub.
|
||||
@@ -771,6 +830,7 @@ class HubAuthenticated(object):
|
||||
A handler that mixes this in must have the following attributes/properties:
|
||||
|
||||
- .hub_auth: A HubAuth instance
|
||||
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
||||
- .hub_users: A set of usernames to allow.
|
||||
If left unspecified or None, username will not be checked.
|
||||
- .hub_groups: A set of group names to allow.
|
||||
@@ -795,13 +855,19 @@ class HubAuthenticated(object):
|
||||
hub_groups = None # set of allowed groups
|
||||
allow_admin = False # allow any admin user access
|
||||
|
||||
@property
|
||||
def hub_scopes(self):
|
||||
"""Set of allowed scopes (use hub_auth.oauth_scopes by default)"""
|
||||
return self.hub_auth.oauth_scopes or None
|
||||
|
||||
@property
|
||||
def allow_all(self):
|
||||
"""Property indicating that all successfully identified user
|
||||
or service should be allowed.
|
||||
"""
|
||||
return (
|
||||
self.hub_services is None
|
||||
self.hub_scopes is None
|
||||
and self.hub_services is None
|
||||
and self.hub_users is None
|
||||
and self.hub_groups is None
|
||||
)
|
||||
@@ -842,22 +908,41 @@ class HubAuthenticated(object):
|
||||
|
||||
Returns the input if the user should be allowed, None otherwise.
|
||||
|
||||
Override if you want to check anything other than the username's presence in hub_users list.
|
||||
Override for custom logic in authenticating users.
|
||||
|
||||
Args:
|
||||
model (dict): the user or service model returned from :class:`HubAuth`
|
||||
user_model (dict): the user or service model returned from :class:`HubAuth`
|
||||
Returns:
|
||||
user_model (dict): The user model if the user should be allowed, None otherwise.
|
||||
"""
|
||||
|
||||
name = model['name']
|
||||
kind = model.setdefault('kind', 'user')
|
||||
|
||||
if self.allow_all:
|
||||
app_log.debug(
|
||||
"Allowing Hub %s %s (all Hub users and services allowed)", kind, name
|
||||
)
|
||||
return model
|
||||
|
||||
if self.hub_scopes:
|
||||
scopes = self.hub_auth.check_scopes(self.hub_scopes, model)
|
||||
if scopes:
|
||||
app_log.debug(
|
||||
f"Allowing Hub {kind} {name} based on oauth scopes {scopes}"
|
||||
)
|
||||
return model
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Not allowing Hub {kind} {name}: missing required scopes"
|
||||
)
|
||||
app_log.debug(
|
||||
f"Hub {kind} {name} needs scope(s) {self.hub_scopes}, has scope(s) {model['scopes']}"
|
||||
)
|
||||
# if hub_scopes are used, *only* hub_scopes are used
|
||||
# note: this means successful authentication, but insufficient permission
|
||||
raise UserNotAllowed(model)
|
||||
|
||||
if self.allow_admin and model.get('admin', False):
|
||||
app_log.debug("Allowing Hub admin %s", name)
|
||||
return model
|
||||
|
@@ -98,6 +98,14 @@ class _ServiceSpawner(LocalProcessSpawner):
|
||||
|
||||
cwd = Unicode()
|
||||
cmd = Command(minlen=0)
|
||||
_service_name = Unicode()
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
return [
|
||||
"access:services",
|
||||
f"access:services!service={self._service_name}",
|
||||
]
|
||||
|
||||
def make_preexec_fn(self, name):
|
||||
if not name:
|
||||
@@ -330,6 +338,10 @@ class Service(LoggingConfigurable):
|
||||
"""
|
||||
return bool(self.server is not None or self.oauth_redirect_uri)
|
||||
|
||||
@property
|
||||
def oauth_client(self):
|
||||
return self.orm.oauth_client
|
||||
|
||||
@property
|
||||
def server(self):
|
||||
if self.orm.server:
|
||||
@@ -384,6 +396,7 @@ class Service(LoggingConfigurable):
|
||||
environment=env,
|
||||
api_token=self.api_token,
|
||||
oauth_client_id=self.oauth_client_id,
|
||||
_service_name=self.name,
|
||||
cookie_options=self.cookie_options,
|
||||
cwd=self.cwd,
|
||||
hub=self.hub,
|
||||
|
@@ -216,6 +216,16 @@ class Spawner(LoggingConfigurable):
|
||||
admin_access = Bool(False)
|
||||
api_token = Unicode()
|
||||
oauth_client_id = Unicode()
|
||||
|
||||
oauth_scopes = List(Unicode())
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
return [
|
||||
f"access:users:server!server={self.user.name}/{self.name}",
|
||||
f"access:users:server!user={self.user.name}",
|
||||
]
|
||||
|
||||
handler = Any()
|
||||
|
||||
oauth_roles = Union(
|
||||
@@ -803,6 +813,8 @@ class Spawner(LoggingConfigurable):
|
||||
self.user.url, self.name, 'oauth_callback'
|
||||
)
|
||||
|
||||
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_scopes)
|
||||
|
||||
# Info previously passed on args
|
||||
env['JUPYTERHUB_USER'] = self.user.name
|
||||
env['JUPYTERHUB_SERVER_NAME'] = self.name
|
||||
|
@@ -21,6 +21,7 @@ from urllib.parse import urlparse
|
||||
import requests
|
||||
from tornado import httpserver
|
||||
from tornado import ioloop
|
||||
from tornado import log
|
||||
from tornado import web
|
||||
|
||||
from jupyterhub.services.auth import HubAuthenticated
|
||||
@@ -114,7 +115,9 @@ def main():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from tornado.options import parse_command_line
|
||||
from tornado.options import parse_command_line, options
|
||||
|
||||
parse_command_line()
|
||||
options.logging = 'debug'
|
||||
log.enable_pretty_logging()
|
||||
main()
|
||||
|
@@ -25,6 +25,7 @@ from tornado.web import HTTPError
|
||||
from tornado.web import RequestHandler
|
||||
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from ..services.auth import _ExpiringDict
|
||||
from ..services.auth import HubOAuth
|
||||
from ..services.auth import HubOAuthenticated
|
||||
@@ -87,6 +88,7 @@ async def test_hubauth_token(app, mockservice_url):
|
||||
public_url(app, mockservice_url) + '/whoami/',
|
||||
headers={'Authorization': 'token %s' % token},
|
||||
)
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
||||
assert sub_reply == {'name': 'river', 'admin': False}
|
||||
@@ -111,36 +113,101 @@ async def test_hubauth_token(app, mockservice_url):
|
||||
assert path.endswith('/hub/login')
|
||||
|
||||
|
||||
async def test_hubauth_service_token(app, mockservice_url):
|
||||
@pytest.mark.parametrize(
|
||||
"scopes, allowed",
|
||||
[
|
||||
(
|
||||
[
|
||||
"access:services",
|
||||
],
|
||||
True,
|
||||
),
|
||||
(
|
||||
[
|
||||
"access:services!service=$service",
|
||||
],
|
||||
True,
|
||||
),
|
||||
(
|
||||
[
|
||||
"access:services!service=other-service",
|
||||
],
|
||||
False,
|
||||
),
|
||||
(
|
||||
[
|
||||
"access:users:servers!user=$service",
|
||||
],
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_hubauth_service_token(request, app, mockservice_url, scopes, allowed):
|
||||
"""Test HubAuthenticated service with service API tokens"""
|
||||
|
||||
scopes = [scope.replace('$service', mockservice_url.name) for scope in scopes]
|
||||
|
||||
token = hexlify(os.urandom(5)).decode('utf8')
|
||||
name = 'test-api-service'
|
||||
app.service_tokens[token] = name
|
||||
await app.init_api_tokens()
|
||||
|
||||
orm_service = app.db.query(orm.Service).filter_by(name=name).one()
|
||||
role_name = "test-hubauth-service-token"
|
||||
|
||||
roles.create_role(
|
||||
app.db,
|
||||
{
|
||||
"name": role_name,
|
||||
"description": "role for test",
|
||||
"scopes": scopes,
|
||||
},
|
||||
)
|
||||
request.addfinalizer(lambda: roles.delete_role(app.db, role_name))
|
||||
roles.grant_role(app.db, orm_service, role_name)
|
||||
|
||||
# token in Authorization header
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/',
|
||||
public_url(app, mockservice_url) + 'whoami/',
|
||||
headers={'Authorization': 'token %s' % token},
|
||||
allow_redirects=False,
|
||||
)
|
||||
if allowed:
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
|
||||
assert reply == {
|
||||
'kind': 'service',
|
||||
'name': name,
|
||||
'admin': False,
|
||||
'roles': [role_name],
|
||||
'scopes': scopes,
|
||||
}
|
||||
assert not r.cookies
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
|
||||
# token in ?token parameter
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/?token=%s' % token
|
||||
public_url(app, mockservice_url) + 'whoami/?token=%s' % token
|
||||
)
|
||||
if allowed:
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
|
||||
assert reply == {
|
||||
'kind': 'service',
|
||||
'name': name,
|
||||
'admin': False,
|
||||
'roles': [role_name],
|
||||
'scopes': scopes,
|
||||
}
|
||||
assert not r.cookies
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/?token=no-such-token',
|
||||
public_url(app, mockservice_url) + 'whoami/?token=no-such-token',
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
|
Reference in New Issue
Block a user