diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index 4a8faebc..584f5c45 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -382,6 +382,23 @@ class Spawner(LoggingConfigurable): scopes.append(f"access:servers!server={self.user.name}/{self.name}") return sorted(set(scopes)) + server_token_scopes = Union( + [List(Unicode()), Callable()], + help="""The list of scopes to request for $JUPYTERHUB_API_TOKEN + + If not specified, the scopes in the `server` role will be used + (unchanged from pre-4.0). + + If callable, will be called with the Spawner instance as its sole argument + (JupyterHub user available as spawner.user). + + JUPYTERHUB_API_TOKEN will be assigned the _subset_ of these scopes + that are held by the user (as in oauth_client_allowed_scopes). + + .. versionadded:: 4.0 + """, + ).tag(config=True) + will_resume = Bool( False, help="""Whether the Spawner will resume on next start diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index cd8dfa73..950a50b6 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -20,7 +20,7 @@ from ..objects import Hub, Server from ..scopes import access_scopes from ..spawner import LocalProcessSpawner, Spawner from ..user import User -from ..utils import AnyTimeoutError, new_token, url_path_join +from ..utils import AnyTimeoutError, maybe_future, new_token, url_path_join from .mocking import public_url from .test_api import add_user from .utils import async_requests @@ -336,6 +336,7 @@ async def test_spawner_insert_api_token(app): assert found assert found.user.name == user.name assert user.api_tokens == [found] + assert set(found.scopes) == set(orm.Role.find(app.db, "server").scopes) await user.stop() @@ -361,6 +362,58 @@ async def test_spawner_bad_api_token(app): assert other_user.api_tokens == [] +@pytest.mark.parametrize( + "have_scopes, request_scopes, expected_scopes", + [ + (["self"], ["inherit"], ["inherit"]), + (["self"], [], ["access:servers!user", "users:activity!user"]), + ( + ["self"], + ["admin:groups", "users:activity!server"], + ["users:activity!server=USER/"], + ), + ( + ["self", "read:groups!group=x"], + ["admin:groups", "users:activity!user"], + ["read:groups!group=x", "read:groups:name!group=x", "users:activity!user"], + ), + ], +) +async def test_server_token_scopes( + app, username, create_user_with_scopes, have_scopes, request_scopes, expected_scopes +): + """Token provided by spawner is not in the db + + Insert token into db as a user-provided token. + """ + db = app.db + + # apply templating + def _format_scopes(scopes): + if callable(scopes): + + async def get_scopes(*args): + return _format_scopes(await maybe_future(scopes(*args))) + + return get_scopes + + return [s.replace("USER", username) for s in scopes] + + have_scopes = _format_scopes(have_scopes) + request_scopes = _format_scopes(request_scopes) + expected_scopes = _format_scopes(expected_scopes) + + user = create_user_with_scopes(*have_scopes, name=username) + spawner = user.spawner + spawner.server_token_scopes = request_scopes + + await user.spawn() + orm_token = orm.APIToken.find(db, spawner.api_token) + assert orm_token + assert set(orm_token.scopes) == set(expected_scopes) + await user.stop() + + async def test_spawner_delete_server(app): """Test deleting spawner.server diff --git a/jupyterhub/user.py b/jupyterhub/user.py index b50ba038..1ff81ddb 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -13,7 +13,7 @@ from tornado import gen, web from tornado.httputil import urlencode from tornado.log import app_log -from . import orm +from . import orm, roles, scopes from ._version import __version__, _check_version from .crypto import CryptKeeper, EncryptionUnavailable, InvalidToken, decrypt, encrypt from .metrics import RUNNING_SERVERS, TOTAL_USERS @@ -673,13 +673,53 @@ class User: orm_server = orm.Server(base_url=base_url) db.add(orm_server) note = "Server at %s" % base_url - api_token = self.new_api_token(note=note, roles=['server']) db.commit() spawner = self.get_spawner(server_name, replace_failed=True) spawner.server = server = Server(orm_server=orm_server) assert spawner.orm_spawner.server is orm_server + requested_scopes = spawner.server_token_scopes + if callable(requested_scopes): + requested_scopes = await maybe_future(requested_scopes(spawner)) + if not requested_scopes: + # nothing requested, default to 'server' role + requested_scopes = orm.Role.find(db, "server").scopes + requested_scopes = set(requested_scopes) + # resolve !server filter, which won't resolve elsewhere, + # because this token is not owned by the server's own oauth client + server_filter = f"={self.name}/{server_name}" + requested_scopes = { + scope + server_filter if scope.endswith("!server") else scope + for scope in requested_scopes + } + have_scopes = roles.roles_to_scopes(roles.get_roles_for(self.orm_user)) + have_scopes |= {"inherit"} + jupyterhub_client = ( + db.query(orm.OAuthClient) + .filter_by( + identifier="jupyterhub", + ) + .one() + ) + + resolved_scopes, excluded_scopes = scopes._resolve_requested_scopes( + requested_scopes, have_scopes, self.orm_user, jupyterhub_client, db + ) + if excluded_scopes: + # what level should this be? + # for admins-get-more use case, this is going to happen for most users + # but for misconfiguration, folks will want to know! + self.log.debug( + "Not assigning requested scopes for %s: requested=%s, assigned=%s, excluded=%s", + spawner._log_name, + requested_scopes, + resolved_scopes, + excluded_scopes, + ) + + api_token = self.new_api_token(note=note, scopes=resolved_scopes) + # pass requesting handler to the spawner # e.g. for processing GET params spawner.handler = handler @@ -808,6 +848,7 @@ class User: spawner.api_token, generated=False, note="retrieved from spawner %s" % server_name, + scopes=resolved_scopes, ) # update OAuth client secret with updated API token if oauth_provider: