From 73b1922c1731a82765cb0a9bf4e7b7dfa84fafbc Mon Sep 17 00:00:00 2001 From: Min RK Date: Wed, 22 Mar 2023 12:03:26 +0100 Subject: [PATCH] add Spawner.server_token_scopes config consistent behavior with oauth_client_allowed_scopes, where the _intersection_ of requested and owner-held permissions is granted, instead of failing Enables different users to have different permissions in $JUPTYERHUB_API_TOKEN, either via callables or via requesting as much as you may want and only granting the subset. Additionally, the !server filter can now be correctly applied to the server token default behavior is unchanged --- jupyterhub/spawner.py | 17 ++++++++++ jupyterhub/tests/test_spawner.py | 55 +++++++++++++++++++++++++++++++- jupyterhub/user.py | 45 ++++++++++++++++++++++++-- 3 files changed, 114 insertions(+), 3 deletions(-) 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: