mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 02:24:08 +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
|
# 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:
|
# 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
|
# start the notebook server as a service
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'shared-notebook',
|
'name': 'shared-notebook',
|
||||||
'url': 'http://127.0.0.1:9999',
|
'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
|
#!/bin/bash -l
|
||||||
set -e
|
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_URL=http://127.0.0.1:9999
|
||||||
export JUPYTERHUB_SERVICE_NAME=shared-notebook
|
export JUPYTERHUB_SERVICE_NAME=shared-notebook
|
||||||
|
export JUPYTERHUB_SERVICE_PREFIX="/services/${JUPYTERHUB_SERVICE_NAME}/"
|
||||||
|
export JUPYTERHUB_CLIENT_ID="service-${JUPYTERHUB_SERVICE_NAME}"
|
||||||
|
|
||||||
jupyterhub-singleuser \
|
jupyterhub-singleuser
|
||||||
--group='shared'
|
|
||||||
|
@@ -216,6 +216,31 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
)
|
)
|
||||||
credentials = self.add_credentials(credentials)
|
credentials = self.add_credentials(credentials)
|
||||||
client = self.oauth_provider.fetch_by_client_id(credentials['client_id'])
|
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):
|
if not self.needs_oauth_confirm(self.current_user, client):
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Skipping oauth confirmation for %s accessing %s",
|
"Skipping oauth confirmation for %s accessing %s",
|
||||||
|
@@ -35,21 +35,31 @@ class SelfAPIHandler(APIHandler):
|
|||||||
user = self.current_user
|
user = self.current_user
|
||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
|
_added_scopes = set()
|
||||||
if isinstance(user, orm.Service):
|
if isinstance(user, orm.Service):
|
||||||
# ensure we have the minimal 'identify' scopes for the token owner
|
# ensure we have the minimal 'identify' scopes for the token owner
|
||||||
self.expanded_scopes.update(scopes.identify_scopes(user))
|
identify_scopes = scopes.identify_scopes(user)
|
||||||
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
get_model = self.service_model
|
||||||
model = self.service_model(user)
|
|
||||||
else:
|
else:
|
||||||
self.expanded_scopes.update(scopes.identify_scopes(user.orm_user))
|
identify_scopes = scopes.identify_scopes(user.orm_user)
|
||||||
print('Expanded scopes in selfapihandler')
|
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)
|
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
||||||
model = self.user_model(user)
|
|
||||||
# validate return, should have at least kind and name,
|
model = get_model(user)
|
||||||
# otherwise our filters did something wrong
|
|
||||||
for key in ("kind", "name"):
|
# add scopes to identify model,
|
||||||
if key not in model:
|
# but not the scopes we added to ensure we could read our own model
|
||||||
raise ValueError(f"Missing identify model for {user}: {model}")
|
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
|
||||||
self.write(json.dumps(model))
|
self.write(json.dumps(model))
|
||||||
|
|
||||||
|
|
||||||
|
@@ -24,11 +24,13 @@ def get_default_roles():
|
|||||||
{
|
{
|
||||||
'name': 'user',
|
'name': 'user',
|
||||||
'description': 'Standard user privileges',
|
'description': 'Standard user privileges',
|
||||||
'scopes': ['self'],
|
'scopes': [
|
||||||
|
'self',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'admin',
|
'name': 'admin',
|
||||||
'description': 'Admin privileges (currently can do everything)',
|
'description': 'Admin privileges (can do everything)',
|
||||||
'scopes': [
|
'scopes': [
|
||||||
'admin:users',
|
'admin:users',
|
||||||
'admin:users:servers',
|
'admin:users:servers',
|
||||||
@@ -38,12 +40,17 @@ def get_default_roles():
|
|||||||
'read:hub',
|
'read:hub',
|
||||||
'proxy',
|
'proxy',
|
||||||
'shutdown',
|
'shutdown',
|
||||||
|
'access:services',
|
||||||
|
'access:users:servers',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'server',
|
'name': 'server',
|
||||||
'description': 'Post activity only',
|
'description': 'Post activity only',
|
||||||
'scopes': ['users:activity!user'],
|
'scopes': [
|
||||||
|
'users:activity!user',
|
||||||
|
'access:users:servers!user',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'token',
|
'name': 'token',
|
||||||
@@ -64,6 +71,7 @@ def expand_self_scope(name):
|
|||||||
users:activity
|
users:activity
|
||||||
users:servers
|
users:servers
|
||||||
users:tokens
|
users:tokens
|
||||||
|
access:users:servers
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
name (str): user name
|
name (str): user name
|
||||||
@@ -80,6 +88,8 @@ def expand_self_scope(name):
|
|||||||
'users:tokens',
|
'users:tokens',
|
||||||
]
|
]
|
||||||
read_scope_list = ['read:' + scope for scope in scope_list]
|
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)
|
scope_list.extend(read_scope_list)
|
||||||
return {"{}!user={}".format(scope, name) for scope in scope_list}
|
return {"{}!user={}".format(scope, name) for scope in scope_list}
|
||||||
|
|
||||||
|
@@ -97,6 +97,12 @@ scope_definitions = {
|
|||||||
'read:hub': {
|
'read:hub': {
|
||||||
'description': 'Read-only access to detailed information about the 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': {
|
'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.'
|
'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}")
|
app_log.warning(f"Authenticated with token {orm_object}")
|
||||||
owner = orm_object.user or orm_object.service
|
owner = orm_object.user or orm_object.service
|
||||||
token_scopes = roles.expand_roles_to_scopes(orm_object)
|
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)
|
owner_scopes = roles.expand_roles_to_scopes(owner)
|
||||||
if 'all' in token_scopes:
|
if 'all' in token_scopes:
|
||||||
token_scopes.remove('all')
|
token_scopes.remove('all')
|
||||||
|
@@ -33,13 +33,50 @@ from traitlets import Dict
|
|||||||
from traitlets import Instance
|
from traitlets import Instance
|
||||||
from traitlets import Integer
|
from traitlets import Integer
|
||||||
from traitlets import observe
|
from traitlets import observe
|
||||||
|
from traitlets import Set
|
||||||
from traitlets import Unicode
|
from traitlets import Unicode
|
||||||
from traitlets import validate
|
from traitlets import validate
|
||||||
from traitlets.config import SingletonConfigurable
|
from traitlets.config import SingletonConfigurable
|
||||||
|
|
||||||
|
from ..scopes import _intersect_scopes
|
||||||
from ..utils import url_path_join
|
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):
|
class _ExpiringDict(dict):
|
||||||
"""Dict-like cache for Hub API requests
|
"""Dict-like cache for Hub API requests
|
||||||
|
|
||||||
@@ -285,6 +322,24 @@ class HubAuth(SingletonConfigurable):
|
|||||||
def _default_cache(self):
|
def _default_cache(self):
|
||||||
return _ExpiringDict(self.cache_max_age)
|
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):
|
def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True):
|
||||||
"""Identify a user with the Hub
|
"""Identify a user with the Hub
|
||||||
|
|
||||||
@@ -495,6 +550,10 @@ class HubAuth(SingletonConfigurable):
|
|||||||
app_log.debug("No user identified")
|
app_log.debug("No user identified")
|
||||||
return user_model
|
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):
|
class HubOAuth(HubAuth):
|
||||||
"""HubAuth using OAuth for login instead of cookies set by the Hub.
|
"""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:
|
A handler that mixes this in must have the following attributes/properties:
|
||||||
|
|
||||||
- .hub_auth: A HubAuth instance
|
- .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.
|
- .hub_users: A set of usernames to allow.
|
||||||
If left unspecified or None, username will not be checked.
|
If left unspecified or None, username will not be checked.
|
||||||
- .hub_groups: A set of group names to allow.
|
- .hub_groups: A set of group names to allow.
|
||||||
@@ -795,13 +855,19 @@ class HubAuthenticated(object):
|
|||||||
hub_groups = None # set of allowed groups
|
hub_groups = None # set of allowed groups
|
||||||
allow_admin = False # allow any admin user access
|
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
|
@property
|
||||||
def allow_all(self):
|
def allow_all(self):
|
||||||
"""Property indicating that all successfully identified user
|
"""Property indicating that all successfully identified user
|
||||||
or service should be allowed.
|
or service should be allowed.
|
||||||
"""
|
"""
|
||||||
return (
|
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_users is None
|
||||||
and self.hub_groups 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.
|
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:
|
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:
|
Returns:
|
||||||
user_model (dict): The user model if the user should be allowed, None otherwise.
|
user_model (dict): The user model if the user should be allowed, None otherwise.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name = model['name']
|
name = model['name']
|
||||||
kind = model.setdefault('kind', 'user')
|
kind = model.setdefault('kind', 'user')
|
||||||
|
|
||||||
if self.allow_all:
|
if self.allow_all:
|
||||||
app_log.debug(
|
app_log.debug(
|
||||||
"Allowing Hub %s %s (all Hub users and services allowed)", kind, name
|
"Allowing Hub %s %s (all Hub users and services allowed)", kind, name
|
||||||
)
|
)
|
||||||
return model
|
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):
|
if self.allow_admin and model.get('admin', False):
|
||||||
app_log.debug("Allowing Hub admin %s", name)
|
app_log.debug("Allowing Hub admin %s", name)
|
||||||
return model
|
return model
|
||||||
|
@@ -98,6 +98,14 @@ class _ServiceSpawner(LocalProcessSpawner):
|
|||||||
|
|
||||||
cwd = Unicode()
|
cwd = Unicode()
|
||||||
cmd = Command(minlen=0)
|
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):
|
def make_preexec_fn(self, name):
|
||||||
if not name:
|
if not name:
|
||||||
@@ -330,6 +338,10 @@ class Service(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
return bool(self.server is not None or self.oauth_redirect_uri)
|
return bool(self.server is not None or self.oauth_redirect_uri)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oauth_client(self):
|
||||||
|
return self.orm.oauth_client
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def server(self):
|
def server(self):
|
||||||
if self.orm.server:
|
if self.orm.server:
|
||||||
@@ -384,6 +396,7 @@ class Service(LoggingConfigurable):
|
|||||||
environment=env,
|
environment=env,
|
||||||
api_token=self.api_token,
|
api_token=self.api_token,
|
||||||
oauth_client_id=self.oauth_client_id,
|
oauth_client_id=self.oauth_client_id,
|
||||||
|
_service_name=self.name,
|
||||||
cookie_options=self.cookie_options,
|
cookie_options=self.cookie_options,
|
||||||
cwd=self.cwd,
|
cwd=self.cwd,
|
||||||
hub=self.hub,
|
hub=self.hub,
|
||||||
|
@@ -216,6 +216,16 @@ class Spawner(LoggingConfigurable):
|
|||||||
admin_access = Bool(False)
|
admin_access = Bool(False)
|
||||||
api_token = Unicode()
|
api_token = Unicode()
|
||||||
oauth_client_id = 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()
|
handler = Any()
|
||||||
|
|
||||||
oauth_roles = Union(
|
oauth_roles = Union(
|
||||||
@@ -803,6 +813,8 @@ class Spawner(LoggingConfigurable):
|
|||||||
self.user.url, self.name, 'oauth_callback'
|
self.user.url, self.name, 'oauth_callback'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_scopes)
|
||||||
|
|
||||||
# Info previously passed on args
|
# Info previously passed on args
|
||||||
env['JUPYTERHUB_USER'] = self.user.name
|
env['JUPYTERHUB_USER'] = self.user.name
|
||||||
env['JUPYTERHUB_SERVER_NAME'] = self.name
|
env['JUPYTERHUB_SERVER_NAME'] = self.name
|
||||||
|
@@ -21,6 +21,7 @@ from urllib.parse import urlparse
|
|||||||
import requests
|
import requests
|
||||||
from tornado import httpserver
|
from tornado import httpserver
|
||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
|
from tornado import log
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubAuthenticated
|
from jupyterhub.services.auth import HubAuthenticated
|
||||||
@@ -114,7 +115,9 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
from tornado.options import parse_command_line
|
from tornado.options import parse_command_line, options
|
||||||
|
|
||||||
parse_command_line()
|
parse_command_line()
|
||||||
|
options.logging = 'debug'
|
||||||
|
log.enable_pretty_logging()
|
||||||
main()
|
main()
|
||||||
|
@@ -25,6 +25,7 @@ from tornado.web import HTTPError
|
|||||||
from tornado.web import RequestHandler
|
from tornado.web import RequestHandler
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
from .. import roles
|
||||||
from ..services.auth import _ExpiringDict
|
from ..services.auth import _ExpiringDict
|
||||||
from ..services.auth import HubOAuth
|
from ..services.auth import HubOAuth
|
||||||
from ..services.auth import HubOAuthenticated
|
from ..services.auth import HubOAuthenticated
|
||||||
@@ -87,6 +88,7 @@ async def test_hubauth_token(app, mockservice_url):
|
|||||||
public_url(app, mockservice_url) + '/whoami/',
|
public_url(app, mockservice_url) + '/whoami/',
|
||||||
headers={'Authorization': 'token %s' % token},
|
headers={'Authorization': 'token %s' % token},
|
||||||
)
|
)
|
||||||
|
r.raise_for_status()
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
||||||
assert sub_reply == {'name': 'river', 'admin': False}
|
assert sub_reply == {'name': 'river', 'admin': False}
|
||||||
@@ -111,36 +113,101 @@ async def test_hubauth_token(app, mockservice_url):
|
|||||||
assert path.endswith('/hub/login')
|
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"""
|
"""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')
|
token = hexlify(os.urandom(5)).decode('utf8')
|
||||||
name = 'test-api-service'
|
name = 'test-api-service'
|
||||||
app.service_tokens[token] = name
|
app.service_tokens[token] = name
|
||||||
await app.init_api_tokens()
|
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
|
# token in Authorization header
|
||||||
r = await async_requests.get(
|
r = await async_requests.get(
|
||||||
public_url(app, mockservice_url) + '/whoami/',
|
public_url(app, mockservice_url) + 'whoami/',
|
||||||
headers={'Authorization': 'token %s' % token},
|
headers={'Authorization': 'token %s' % token},
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
|
if allowed:
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
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
|
assert not r.cookies
|
||||||
|
else:
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
# token in ?token parameter
|
# token in ?token parameter
|
||||||
r = await async_requests.get(
|
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()
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
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(
|
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,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
assert r.status_code == 302
|
assert r.status_code == 302
|
||||||
|
Reference in New Issue
Block a user