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
This commit is contained in:
Min RK
2023-03-22 12:03:26 +01:00
parent 64a253dbef
commit 73b1922c17
3 changed files with 114 additions and 3 deletions

View File

@@ -382,6 +382,23 @@ class Spawner(LoggingConfigurable):
scopes.append(f"access:servers!server={self.user.name}/{self.name}") scopes.append(f"access:servers!server={self.user.name}/{self.name}")
return sorted(set(scopes)) 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( will_resume = Bool(
False, False,
help="""Whether the Spawner will resume on next start help="""Whether the Spawner will resume on next start

View File

@@ -20,7 +20,7 @@ from ..objects import Hub, Server
from ..scopes import access_scopes from ..scopes import access_scopes
from ..spawner import LocalProcessSpawner, Spawner from ..spawner import LocalProcessSpawner, Spawner
from ..user import User 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 .mocking import public_url
from .test_api import add_user from .test_api import add_user
from .utils import async_requests from .utils import async_requests
@@ -336,6 +336,7 @@ async def test_spawner_insert_api_token(app):
assert found assert found
assert found.user.name == user.name assert found.user.name == user.name
assert user.api_tokens == [found] assert user.api_tokens == [found]
assert set(found.scopes) == set(orm.Role.find(app.db, "server").scopes)
await user.stop() await user.stop()
@@ -361,6 +362,58 @@ async def test_spawner_bad_api_token(app):
assert other_user.api_tokens == [] 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): async def test_spawner_delete_server(app):
"""Test deleting spawner.server """Test deleting spawner.server

View File

@@ -13,7 +13,7 @@ from tornado import gen, web
from tornado.httputil import urlencode from tornado.httputil import urlencode
from tornado.log import app_log from tornado.log import app_log
from . import orm from . import orm, roles, scopes
from ._version import __version__, _check_version from ._version import __version__, _check_version
from .crypto import CryptKeeper, EncryptionUnavailable, InvalidToken, decrypt, encrypt from .crypto import CryptKeeper, EncryptionUnavailable, InvalidToken, decrypt, encrypt
from .metrics import RUNNING_SERVERS, TOTAL_USERS from .metrics import RUNNING_SERVERS, TOTAL_USERS
@@ -673,13 +673,53 @@ class User:
orm_server = orm.Server(base_url=base_url) orm_server = orm.Server(base_url=base_url)
db.add(orm_server) db.add(orm_server)
note = "Server at %s" % base_url note = "Server at %s" % base_url
api_token = self.new_api_token(note=note, roles=['server'])
db.commit() db.commit()
spawner = self.get_spawner(server_name, replace_failed=True) spawner = self.get_spawner(server_name, replace_failed=True)
spawner.server = server = Server(orm_server=orm_server) spawner.server = server = Server(orm_server=orm_server)
assert spawner.orm_spawner.server is 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 # pass requesting handler to the spawner
# e.g. for processing GET params # e.g. for processing GET params
spawner.handler = handler spawner.handler = handler
@@ -808,6 +848,7 @@ class User:
spawner.api_token, spawner.api_token,
generated=False, generated=False,
note="retrieved from spawner %s" % server_name, note="retrieved from spawner %s" % server_name,
scopes=resolved_scopes,
) )
# update OAuth client secret with updated API token # update OAuth client secret with updated API token
if oauth_provider: if oauth_provider: