diff --git a/examples/service-notebook/external/jupyterhub_config.py b/examples/service-notebook/external/jupyterhub_config.py index 3b2ef52e..c6157253 100644 --- a/examples/service-notebook/external/jupyterhub_config.py +++ b/examples/service-notebook/external/jupyterhub_config.py @@ -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' diff --git a/examples/service-notebook/external/shared-notebook-service b/examples/service-notebook/external/shared-notebook-service index 20206e92..3510c0a6 100755 --- a/examples/service-notebook/external/shared-notebook-service +++ b/examples/service-notebook/external/shared-notebook-service @@ -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 diff --git a/jupyterhub/apihandlers/auth.py b/jupyterhub/apihandlers/auth.py index 4e7673f2..7506dd40 100644 --- a/jupyterhub/apihandlers/auth.py +++ b/jupyterhub/apihandlers/auth.py @@ -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", diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index a33bef20..9bb379ed 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -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)) diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index 73c171dd..6417a3e6 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -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} diff --git a/jupyterhub/scopes.py b/jupyterhub/scopes.py index 1c73108e..afce5974 100644 --- a/jupyterhub/scopes.py +++ b/jupyterhub/scopes.py @@ -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') diff --git a/jupyterhub/services/auth.py b/jupyterhub/services/auth.py index 4f8e0524..1066a9d7 100644 --- a/jupyterhub/services/auth.py +++ b/jupyterhub/services/auth.py @@ -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 diff --git a/jupyterhub/services/service.py b/jupyterhub/services/service.py index f556b6da..24d19592 100644 --- a/jupyterhub/services/service.py +++ b/jupyterhub/services/service.py @@ -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, diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index fd18386c..39b50133 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -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 diff --git a/jupyterhub/tests/mockservice.py b/jupyterhub/tests/mockservice.py index 415f512f..082aa65d 100644 --- a/jupyterhub/tests/mockservice.py +++ b/jupyterhub/tests/mockservice.py @@ -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() diff --git a/jupyterhub/tests/test_services_auth.py b/jupyterhub/tests/test_services_auth.py index 3ed5d628..158bdbf9 100644 --- a/jupyterhub/tests/test_services_auth.py +++ b/jupyterhub/tests/test_services_auth.py @@ -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, ) - r.raise_for_status() - assert r.status_code == 200 - reply = r.json() - assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []} - assert not r.cookies + if allowed: + r.raise_for_status() + assert r.status_code == 200 + reply = r.json() + 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 ) - r.raise_for_status() - reply = r.json() - assert reply == {'kind': 'service', 'name': name, 'admin': False, 'roles': []} + if allowed: + r.raise_for_status() + assert r.status_code == 200 + reply = r.json() + 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