mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-12 04:23:01 +00:00
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:
@@ -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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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:
|
||||
|
Reference in New Issue
Block a user