diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d4a52dc3..f9bc7ae7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,11 +6,11 @@ repos: args: - --py36-plus - repo: https://github.com/asottile/reorder_python_imports - rev: v2.6.0 + rev: v2.7.1 hooks: - id: reorder-python-imports - repo: https://github.com/psf/black - rev: 21.12b0 + rev: 22.1.0 hooks: - id: black - repo: https://github.com/pre-commit/mirrors-prettier diff --git a/readthedocs.yml b/.readthedocs.yaml similarity index 75% rename from readthedocs.yml rename to .readthedocs.yaml index b7c0d3f0..c1e09b1a 100644 --- a/readthedocs.yml +++ b/.readthedocs.yaml @@ -4,10 +4,12 @@ sphinx: configuration: docs/source/conf.py build: - image: latest + os: ubuntu-20.04 + tools: + nodejs: "16" + python: "3.9" python: - version: 3.7 install: - method: pip path: . diff --git a/docs/source/_static/rest-api.yml b/docs/source/_static/rest-api.yml index 995a71a3..17fbf9d2 100644 --- a/docs/source/_static/rest-api.yml +++ b/docs/source/_static/rest-api.yml @@ -6,7 +6,7 @@ info: description: The REST API for JupyterHub license: name: BSD-3-Clause - version: 2.1.0.dev + version: 2.2.0.dev servers: - url: /hub/api security: @@ -1419,3 +1419,4 @@ components: Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy. shutdown: Shutdown the hub. + read:metrics: Read prometheus metrics. diff --git a/docs/source/changelog.md b/docs/source/changelog.md index 5275fd50..8a19b2c9 100644 --- a/docs/source/changelog.md +++ b/docs/source/changelog.md @@ -6,6 +6,68 @@ command line for details. ## [Unreleased] +## 2.1 + +### 2.1.1 2021-01-25 + +2.1.1 is a tiny bugfix release, +fixing an issue where admins did not receive the new `read:metrics` permission. + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.1.0...2.1.1)) + +#### Bugs fixed + +- add missing read:metrics scope to admin role [#3778](https://github.com/jupyterhub/jupyterhub/pull/3778) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio)) + +#### Contributors to this release + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-01-21&to=2022-01-25&type=c)) + +[@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-01-21..2022-01-25&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adependabot+updated%3A2022-01-21..2022-01-25&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2022-01-21..2022-01-25&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-01-21..2022-01-25&type=Issues) + +### 2.1.0 2022-01-21 + +2.1.0 is a small bugfix release, resolving regressions in 2.0 and further refinements. +In particular, the authenticated prometheus metrics endpoint did not work in 2.0 because it lacked a scope. +To access the authenticated metrics endpoint with a token, +upgrade to 2.1 and make sure the token/owner has the `read:metrics` scope. + +Custom error messages for failed spawns are now handled more consistently on the spawn-progress API and the spawn-failed HTML page. +Previously, spawn-progress did not relay the custom message provided by `exception.jupyterhub_message`, +and full HTML messages in `exception.jupyterhub_html_message` can now be displayed in both contexts. + +The long-deprecated, inconsistent behavior when users visited a URL for another user's server, +where they could sometimes be redirected back to their own server, +has been removed in favor of consistent behavior based on the user's permissions. +To share a URL that will take any user to their own server, use `https://my.hub/hub/user-redirect/path/...`. + +([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.2...2.1.0)) + +#### Enhancements made + +- relay custom messages in exception.jupyterhub_message in progress API [#3764](https://github.com/jupyterhub/jupyterhub/pull/3764) ([@minrk](https://github.com/minrk)) +- Add the capability to inform a connection to Alembic Migration Script [#3762](https://github.com/jupyterhub/jupyterhub/pull/3762) ([@DougTrajano](https://github.com/DougTrajano)) + +#### Bugs fixed + +- Fix loading Spawner.user_options from db [#3773](https://github.com/jupyterhub/jupyterhub/pull/3773) ([@IgorBerman](https://github.com/IgorBerman)) +- Add missing `read:metrics` scope for authenticated metrics endpoint [#3770](https://github.com/jupyterhub/jupyterhub/pull/3770) ([@minrk](https://github.com/minrk)) +- apply scope checks to some admin-or-self situations [#3763](https://github.com/jupyterhub/jupyterhub/pull/3763) ([@minrk](https://github.com/minrk)) + +#### Maintenance and upkeep improvements + +- DOCS: Add github metadata for edit button [#3775](https://github.com/jupyterhub/jupyterhub/pull/3775) ([@minrk](https://github.com/minrk)) + +#### Documentation improvements + +- Improve documentation about spawner exception handling [#3765](https://github.com/jupyterhub/jupyterhub/pull/3765) ([@twalcari](https://github.com/twalcari)) + +#### Contributors to this release + +([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-01-10&to=2022-01-21&type=c)) + +[@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-01-10..2022-01-21&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adependabot+updated%3A2022-01-10..2022-01-21&type=Issues) | [@DougTrajano](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ADougTrajano+updated%3A2022-01-10..2022-01-21&type=Issues) | [@IgorBerman](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AIgorBerman+updated%3A2022-01-10..2022-01-21&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-01-10..2022-01-21&type=Issues) | [@twalcari](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atwalcari+updated%3A2022-01-10..2022-01-21&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awelcome+updated%3A2022-01-10..2022-01-21&type=Issues) + ## 2.0 ### [2.0.2] 2022-01-10 @@ -1389,7 +1451,8 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers. First preview release -[unreleased]: https://github.com/jupyterhub/jupyterhub/compare/2.0.2...HEAD +[unreleased]: https://github.com/jupyterhub/jupyterhub/compare/2.1.0...HEAD +[2.1.0]: https://github.com/jupyterhub/jupyterhub/compare/2.0.2...2.1.0 [2.0.2]: https://github.com/jupyterhub/jupyterhub/compare/2.0.1...2.0.2 [2.0.1]: https://github.com/jupyterhub/jupyterhub/compare/2.0.0...2.0.1 [2.0.0]: https://github.com/jupyterhub/jupyterhub/compare/1.5.0...2.0.0 diff --git a/docs/source/conf.py b/docs/source/conf.py index c45fa0cf..c3f10040 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -147,6 +147,13 @@ html_theme_options = { "navbar_align": "left", } +html_context = { + "github_user": "jupyterhub", + "github_repo": "jupyterhub", + "github_version": "main", + "doc_path": "docs", +} + # -- Options for LaTeX output --------------------------------------------- latex_elements = { diff --git a/docs/source/reference/authenticators.md b/docs/source/reference/authenticators.md index 068fc248..61f8ecbe 100644 --- a/docs/source/reference/authenticators.md +++ b/docs/source/reference/authenticators.md @@ -247,6 +247,36 @@ class MyAuthenticator(Authenticator): spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token'] ``` +## Authenticator-managed group membership + +:::{versionadded} 2.2 +::: + +Some identity providers may have their own concept of group membership that you would like to preserve in JupyterHub. +This is now possible with `Authenticator.managed_groups`. + +You can set the config: + +```python +c.Authenticator.manage_groups = True +``` + +to enable this behavior. +The default is False for Authenticators that ship with JupyterHub, +but may be True for custom Authenticators. +Check your Authenticator's documentation for manage_groups support. + +If True, {meth}`.Authenticator.authenticate` and {meth}`.Authenticator.refresh_user` may include a field `groups` +which is a list of group names the user should be a member of: + +- Membership will be added for any group in the list +- Membership in any groups not in the list will be revoked +- Any groups not already present in the database will be created +- If `None` is returned, no changes are made to the user's group membership + +If authenticator-managed groups are enabled, +all group-management via the API is disabled. + ## pre_spawn_start and post_spawn_stop hooks Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and diff --git a/docs/source/reference/spawners.md b/docs/source/reference/spawners.md index ce39b598..f91ec320 100644 --- a/docs/source/reference/spawners.md +++ b/docs/source/reference/spawners.md @@ -108,6 +108,16 @@ class MySpawner(Spawner): return url ``` +#### Exception handling + +When `Spawner.start` raises an Exception, a message can be passed on to the user via the exception via a `.jupyterhub_html_message` or `.jupyterhub_message` attribute. + +When the Exception has a `.jupyterhub_html_message` attribute, it will be rendered as HTML to the user. + +Alternatively `.jupyterhub_message` is rendered as unformatted text. + +If both attributes are not present, the Exception will be shown to the user as unformatted text. + ### Spawner.poll `Spawner.poll` should check if the spawner is still running. diff --git a/examples/azuread-with-group-management/jupyterhub_config.py b/examples/azuread-with-group-management/jupyterhub_config.py new file mode 100644 index 00000000..f8da8746 --- /dev/null +++ b/examples/azuread-with-group-management/jupyterhub_config.py @@ -0,0 +1,30 @@ +"""sample jupyterhub config file for testing + +configures jupyterhub with dummyauthenticator and simplespawner +to enable testing without administrative privileges. +""" + +c = get_config() # noqa +c.Application.log_level = 'DEBUG' + +from oauthenticator.azuread import AzureAdOAuthenticator +import os + +c.JupyterHub.authenticator_class = AzureAdOAuthenticator + +c.AzureAdOAuthenticator.client_id = os.getenv("AAD_CLIENT_ID") +c.AzureAdOAuthenticator.client_secret = os.getenv("AAD_CLIENT_SECRET") +c.AzureAdOAuthenticator.oauth_callback_url = os.getenv("AAD_CALLBACK_URL") +c.AzureAdOAuthenticator.tenant_id = os.getenv("AAD_TENANT_ID") +c.AzureAdOAuthenticator.username_claim = "email" +c.AzureAdOAuthenticator.authorize_url = os.getenv("AAD_AUTHORIZE_URL") +c.AzureAdOAuthenticator.token_url = os.getenv("AAD_TOKEN_URL") +c.Authenticator.manage_groups = True +c.Authenticator.refresh_pre_spawn = True + +# Optionally set a global password that all users must use +# c.DummyAuthenticator.password = "your_password" + +from jupyterhub.spawner import SimpleLocalProcessSpawner + +c.JupyterHub.spawner_class = SimpleLocalProcessSpawner diff --git a/examples/azuread-with-group-management/requirements.txt b/examples/azuread-with-group-management/requirements.txt new file mode 100644 index 00000000..6fda532a --- /dev/null +++ b/examples/azuread-with-group-management/requirements.txt @@ -0,0 +1,2 @@ +oauthenticator +pyjwt diff --git a/jsx/yarn.lock b/jsx/yarn.lock index f2c26c2c..ae18c53b 100644 --- a/jsx/yarn.lock +++ b/jsx/yarn.lock @@ -3664,9 +3664,9 @@ flatted@^3.1.0: integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== follow-redirects@^1.0.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" - integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== + version "1.14.7" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685" + integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ== for-in@^1.0.2: version "1.0.2" @@ -5636,9 +5636,9 @@ nan@^2.12.1: integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== nanoid@^3.1.23: - version "3.1.23" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" - integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw== + version "3.2.0" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" + integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA== nanomatch@^1.2.9: version "1.2.13" diff --git a/jupyterhub/_version.py b/jupyterhub/_version.py index e9cba74c..0cb882c1 100644 --- a/jupyterhub/_version.py +++ b/jupyterhub/_version.py @@ -2,7 +2,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. # version_info updated by running `tbump` -version_info = (2, 1, 0, "", "dev") +version_info = (2, 2, 0, "", "dev") # pep 440 version: no dot before beta/rc, but before .dev # 0.1.0rc1 diff --git a/jupyterhub/apihandlers/groups.py b/jupyterhub/apihandlers/groups.py index 4ac0d983..574ab1e4 100644 --- a/jupyterhub/apihandlers/groups.py +++ b/jupyterhub/apihandlers/groups.py @@ -33,6 +33,11 @@ class _GroupAPIHandler(APIHandler): raise web.HTTPError(404, "No such group: %s", group_name) return group + def check_authenticator_managed_groups(self): + """Raise error on group-management APIs if Authenticator is managing groups""" + if self.authenticator.manage_groups: + raise web.HTTPError(400, "Group management via API is disabled") + class GroupListAPIHandler(_GroupAPIHandler): @needs_scope('list:groups') @@ -68,6 +73,9 @@ class GroupListAPIHandler(_GroupAPIHandler): @needs_scope('admin:groups') async def post(self): """POST creates Multiple groups""" + + self.check_authenticator_managed_groups() + model = self.get_json_body() if not model or not isinstance(model, dict) or not model.get('groups'): raise web.HTTPError(400, "Must specify at least one group to create") @@ -106,6 +114,7 @@ class GroupAPIHandler(_GroupAPIHandler): @needs_scope('admin:groups') async def post(self, group_name): """POST creates a group by name""" + self.check_authenticator_managed_groups() model = self.get_json_body() if model is None: model = {} @@ -132,6 +141,7 @@ class GroupAPIHandler(_GroupAPIHandler): @needs_scope('delete:groups') def delete(self, group_name): """Delete a group by name""" + self.check_authenticator_managed_groups() group = self.find_group(group_name) self.log.info("Deleting group %s", group_name) self.db.delete(group) @@ -145,6 +155,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler): @needs_scope('groups') def post(self, group_name): """POST adds users to a group""" + self.check_authenticator_managed_groups() group = self.find_group(group_name) data = self.get_json_body() self._check_group_model(data) @@ -163,6 +174,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler): @needs_scope('groups') async def delete(self, group_name): """DELETE removes users from a group""" + self.check_authenticator_managed_groups() group = self.find_group(group_name) data = self.get_json_body() self._check_group_model(data) diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 2d0bfeff..2e3cbae3 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -714,7 +714,12 @@ class SpawnProgressAPIHandler(APIHandler): # check if spawner has just failed f = spawn_future if f and f.done() and f.exception(): - failed_event['message'] = "Spawn failed: %s" % f.exception() + exc = f.exception() + message = getattr(exc, "jupyterhub_message", str(exc)) + failed_event['message'] = f"Spawn failed: {message}" + html_message = getattr(exc, "jupyterhub_html_message", "") + if html_message: + failed_event['html_message'] = html_message await self.send_event(failed_event) return else: @@ -747,7 +752,12 @@ class SpawnProgressAPIHandler(APIHandler): # what happened? Maybe spawn failed? f = spawn_future if f and f.done() and f.exception(): - failed_event['message'] = "Spawn failed: %s" % f.exception() + exc = f.exception() + message = getattr(exc, "jupyterhub_message", str(exc)) + failed_event['message'] = f"Spawn failed: {message}" + html_message = getattr(exc, "jupyterhub_html_message", "") + if html_message: + failed_event['html_message'] = html_message else: self.log.warning( "Server %s didn't start for unknown reason", spawner._log_name diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 5d1590dd..c08ee050 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -2001,6 +2001,9 @@ class JupyterHub(Application): async def init_groups(self): """Load predefined groups into the database""" db = self.db + + if self.authenticator.manage_groups and self.load_groups: + raise ValueError("Group management has been offloaded to the authenticator") for name, usernames in self.load_groups.items(): group = orm.Group.find(db, name) if group is None: @@ -3147,7 +3150,12 @@ class JupyterHub(Application): self.last_activity_callback = pc pc.start() - self.log.info("JupyterHub is now running at %s", self.proxy.public_url) + if self.proxy.should_start: + self.log.info("JupyterHub is now running at %s", self.proxy.public_url) + else: + self.log.info( + "JupyterHub is now running, internal Hub API at %s", self.hub.url + ) # Use atexit for Windows, it doesn't have signal handling support if _mswindows: atexit.register(self.atexit) diff --git a/jupyterhub/auth.py b/jupyterhub/auth.py index 41ad1058..1ac38988 100644 --- a/jupyterhub/auth.py +++ b/jupyterhub/auth.py @@ -582,9 +582,13 @@ class Authenticator(LoggingConfigurable): or None if Authentication failed. The Authenticator may return a dict instead, which MUST have a - key `name` holding the username, and MAY have two optional keys - set: `auth_state`, a dictionary of of auth state that will be - persisted; and `admin`, the admin setting value for the user. + key `name` holding the username, and MAY have additional keys: + + - `auth_state`, a dictionary of of auth state that will be + persisted; + - `admin`, the admin setting value for the user + - `groups`, the list of group names the user should be a member of, + if Authenticator.manage_groups is True. """ def pre_spawn_start(self, user, spawner): @@ -635,6 +639,19 @@ class Authenticator(LoggingConfigurable): """ self.allowed_users.discard(user.name) + manage_groups = Bool( + False, + config=True, + help="""Let authenticator manage user groups + + If True, Authenticator.authenticate and/or .refresh_user + may return a list of group names in the 'groups' field, + which will be assigned to the user. + + All group-assignment APIs are disabled if this is True. + """, + ) + auto_login = Bool( False, config=True, diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index 56cf1fa8..385fb339 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -45,6 +45,7 @@ from ..metrics import ServerSpawnStatus from ..metrics import ServerStopStatus from ..metrics import TOTAL_USERS from ..objects import Server +from ..scopes import needs_scope from ..spawner import LocalProcessSpawner from ..user import User from ..utils import AnyTimeoutError @@ -773,13 +774,22 @@ class BaseHandler(RequestHandler): # always ensure default roles ('user', 'admin' if admin) are assigned # after a successful login roles.assign_default_roles(self.db, entity=user) + + # apply authenticator-managed groups + if self.authenticator.manage_groups: + group_names = authenticated.get("groups") + if group_names is not None: + user.sync_groups(group_names) + # always set auth_state and commit, # because there could be key-rotation or clearing of previous values # going on. if not self.authenticator.enable_auth_state: # auth_state is not enabled. Force None. auth_state = None + await user.save_auth_state(auth_state) + return user async def login_user(self, data=None): @@ -793,6 +803,7 @@ class BaseHandler(RequestHandler): self.set_login_cookie(user) self.statsd.incr('login.success') self.statsd.timing('login.authenticate.success', auth_timer.ms) + self.log.info("User logged in: %s", user.name) user._auth_refreshed = time.monotonic() return user @@ -1448,54 +1459,24 @@ class UserUrlHandler(BaseHandler): delete = non_get @web.authenticated + @needs_scope("access:servers") async def get(self, user_name, user_path): if not user_path: user_path = '/' current_user = self.current_user - - if ( - current_user - and current_user.name != user_name - and current_user.admin - and self.settings.get('admin_access', False) - ): - # allow admins to spawn on behalf of users + if user_name != current_user.name: user = self.find_user(user_name) if user is None: # no such user - raise web.HTTPError(404, "No such user %s" % user_name) + raise web.HTTPError(404, f"No such user {user_name}") self.log.info( - "Admin %s requesting spawn on behalf of %s", - current_user.name, - user.name, + f"User {current_user.name} requesting spawn on behalf of {user.name}" ) admin_spawn = True should_spawn = True redirect_to_self = False else: user = current_user - admin_spawn = False - # For non-admins, spawn if the user requested is the current user - # otherwise redirect users to their own server - should_spawn = current_user and current_user.name == user_name - redirect_to_self = not should_spawn - - if redirect_to_self: - # logged in as a different non-admin user, redirect to user's own server - # this is only a stop-gap for a common mistake, - # because the same request will be a 403 - # if the requested server is running - self.statsd.incr('redirects.user_to_user', 1) - self.log.warning( - "User %s requested server for %s, which they don't own", - current_user.name, - user_name, - ) - target = url_path_join(current_user.url, user_path or '') - if self.request.query: - target = url_concat(target, parse_qsl(self.request.query)) - self.redirect(target) - return # If people visit /user/:user_name directly on the Hub, # the redirects will just loop, because the proxy is bypassed. @@ -1539,14 +1520,10 @@ class UserUrlHandler(BaseHandler): # if request is expecting JSON, assume it's an API request and fail with 503 # because it won't like the redirect to the pending page - if ( - get_accepted_mimetype( - self.request.headers.get('Accept', ''), - choices=['application/json', 'text/html'], - ) - == 'application/json' - or 'api' in user_path.split('/') - ): + if get_accepted_mimetype( + self.request.headers.get('Accept', ''), + choices=['application/json', 'text/html'], + ) == 'application/json' or 'api' in user_path.split('/'): self._fail_api_request(user_name, server_name) return @@ -1628,7 +1605,7 @@ class UserUrlHandler(BaseHandler): if redirects: self.log.warning("Redirect loop detected on %s", self.request.uri) # add capped exponential backoff where cap is 10s - await asyncio.sleep(min(1 * (2 ** redirects), 10)) + await asyncio.sleep(min(1 * (2**redirects), 10)) # rewrite target url with new `redirects` query value url_parts = urlparse(target) query_parts = parse_qs(url_parts.query) diff --git a/jupyterhub/handlers/metrics.py b/jupyterhub/handlers/metrics.py index d2f0b03b..844a203f 100644 --- a/jupyterhub/handlers/metrics.py +++ b/jupyterhub/handlers/metrics.py @@ -12,6 +12,8 @@ class MetricsHandler(BaseHandler): Handler to serve Prometheus metrics """ + _accept_token_auth = True + @metrics_authentication async def get(self): self.set_header('Content-Type', CONTENT_TYPE_LATEST) diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index 5f56183b..e7921ff1 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -106,22 +106,27 @@ class SpawnHandler(BaseHandler): ) @web.authenticated - async def get(self, for_user=None, server_name=''): + def get(self, user_name=None, server_name=''): """GET renders form for spawning with user-specified options or triggers spawn via redirect if there is no form. """ + # two-stage to get the right signature for @require_scopes filter on user_name + if user_name is None: + user_name = self.current_user.name + if server_name is None: + server_name = "" + return self._get(user_name=user_name, server_name=server_name) + + @needs_scope("servers") + async def _get(self, user_name, server_name): + for_user = user_name user = current_user = self.current_user - if for_user is not None and for_user != user.name: - if not user.admin: - raise web.HTTPError( - 403, "Only admins can spawn on behalf of other users" - ) - + if for_user != user.name: user = self.find_user(for_user) if user is None: - raise web.HTTPError(404, "No such user: %s" % for_user) + raise web.HTTPError(404, f"No such user: {for_user}") if server_name: if not self.allow_named_servers: @@ -141,14 +146,11 @@ class SpawnHandler(BaseHandler): ) if not self.allow_named_servers and user.running: - url = self.get_next_url(user, default=user.server_url(server_name)) + url = self.get_next_url(user, default=user.server_url("")) self.log.info("User is running: %s", user.name) self.redirect(url) return - if server_name is None: - server_name = '' - spawner = user.spawners[server_name] pending_url = self._get_pending_url(user, server_name) @@ -189,7 +191,6 @@ class SpawnHandler(BaseHandler): spawner._log_name, ) options = await maybe_future(spawner.options_from_query(query_options)) - pending_url = self._get_pending_url(user, server_name) return await self._wrap_spawn_single_user( user, server_name, spawner, pending_url, options ) @@ -219,14 +220,19 @@ class SpawnHandler(BaseHandler): ) @web.authenticated - async def post(self, for_user=None, server_name=''): + def post(self, user_name=None, server_name=''): """POST spawns with user-specified options""" + if user_name is None: + user_name = self.current_user.name + if server_name is None: + server_name = "" + return self._post(user_name=user_name, server_name=server_name) + + @needs_scope("servers") + async def _post(self, user_name, server_name): + for_user = user_name user = current_user = self.current_user - if for_user is not None and for_user != user.name: - if not user.admin: - raise web.HTTPError( - 403, "Only admins can spawn on behalf of other users" - ) + if for_user != user.name: user = self.find_user(for_user) if user is None: raise web.HTTPError(404, "No such user: %s" % for_user) @@ -337,13 +343,11 @@ class SpawnPendingHandler(BaseHandler): """ @web.authenticated - async def get(self, for_user, server_name=''): + @needs_scope("servers") + async def get(self, user_name, server_name=''): + for_user = user_name user = current_user = self.current_user - if for_user is not None and for_user != current_user.name: - if not current_user.admin: - raise web.HTTPError( - 403, "Only admins can spawn on behalf of other users" - ) + if for_user != current_user.name: user = self.find_user(for_user) if user is None: raise web.HTTPError(404, "No such user: %s" % for_user) @@ -387,6 +391,7 @@ class SpawnPendingHandler(BaseHandler): server_name=server_name, spawn_url=spawn_url, failed=True, + failed_html_message=getattr(exc, 'jupyterhub_html_message', ''), failed_message=getattr(exc, 'jupyterhub_message', ''), exception=exc, ) diff --git a/jupyterhub/roles.py b/jupyterhub/roles.py index defa187d..75545a9a 100644 --- a/jupyterhub/roles.py +++ b/jupyterhub/roles.py @@ -45,6 +45,7 @@ def get_default_roles(): 'access:services', 'access:servers', 'read:roles', + 'read:metrics', ], }, { diff --git a/jupyterhub/scopes.py b/jupyterhub/scopes.py index e8f8ff4f..5f441fbe 100644 --- a/jupyterhub/scopes.py +++ b/jupyterhub/scopes.py @@ -131,6 +131,9 @@ scope_definitions = { 'description': 'Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy.' }, 'shutdown': {'description': 'Shutdown the hub.'}, + 'read:metrics': { + 'description': "Read prometheus metrics.", + }, } diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py index d1401ff7..857de247 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -493,7 +493,7 @@ class SingleUserNotebookAppMixin(Configurable): i, RETRIES, ) - await asyncio.sleep(min(2 ** i, 16)) + await asyncio.sleep(min(2**i, 16)) else: break else: diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 477d49aa..968fea70 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -1843,6 +1843,61 @@ async def test_group_add_delete_users(app): assert sorted(u.name for u in group.users) == sorted(names[2:]) +@mark.group +async def test_auth_managed_groups(request, app, group, user): + group.users.append(user) + app.db.commit() + app.authenticator.manage_groups = True + request.addfinalizer(lambda: setattr(app.authenticator, "manage_groups", False)) + # create groups + r = await api_request(app, 'groups', method='post') + assert r.status_code == 400 + r = await api_request(app, 'groups/newgroup', method='post') + assert r.status_code == 400 + # delete groups + r = await api_request(app, f'groups/{group.name}', method='delete') + assert r.status_code == 400 + # add users to group + r = await api_request( + app, + f'groups/{group.name}/users', + method='post', + data=json.dumps({"users": [user.name]}), + ) + assert r.status_code == 400 + # remove users from group + r = await api_request( + app, + f'groups/{group.name}/users', + method='delete', + data=json.dumps({"users": [user.name]}), + ) + assert r.status_code == 400 + +@mark.group +async def test_group_add_properties(app): + db = app.db + group = orm.Group(name='alphaflight') + app.db.add(group) + app.db.commit() + r = await api_request(app, 'groups/alphaflight/properties', method='put', data='{}') + assert r.status_code == 200 + + properties_object = {'cpu': "8", 'ram': "4", 'image': "testimage"} + + r = await api_request( + app, + 'groups/alphaflight/properties', + method='put', + data=json.dumps(properties_object), + ) + r.raise_for_status() + group = orm.Group.find(db, name='alphaflight') + + assert sorted(k for k in group.properties) == sorted(k for k in properties_object) + assert sorted(group.properties[k] for k in group.properties) == sorted( + properties_object[k] for k in properties_object + ) # ----------------- # Service API tests diff --git a/jupyterhub/tests/test_auth.py b/jupyterhub/tests/test_auth.py index 1f627426..667a046d 100644 --- a/jupyterhub/tests/test_auth.py +++ b/jupyterhub/tests/test_auth.py @@ -7,6 +7,7 @@ from urllib.parse import urlparse import pytest from requests import HTTPError +from traitlets import Any from traitlets.config import Config from .mocking import MockPAMAuthenticator @@ -14,6 +15,7 @@ from .mocking import MockStructGroup from .mocking import MockStructPasswd from .utils import add_user from .utils import async_requests +from .utils import get_page from .utils import public_url from jupyterhub import auth from jupyterhub import crypto @@ -527,3 +529,71 @@ async def test_nullauthenticator(app): r = await async_requests.get(public_url(app)) assert urlparse(r.url).path.endswith("/hub/login") assert r.status_code == 403 + + +class MockGroupsAuthenticator(auth.Authenticator): + authenticated_groups = Any() + refresh_groups = Any() + + manage_groups = True + + def authenticate(self, handler, data): + return { + "name": data["username"], + "groups": self.authenticated_groups, + } + + async def refresh_user(self, user, handler): + return { + "name": user.name, + "groups": self.refresh_groups, + } + + +@pytest.mark.parametrize( + "authenticated_groups, refresh_groups", + [ + (None, None), + (["auth1"], None), + (None, ["auth1"]), + (["auth1"], ["auth1", "auth2"]), + (["auth1", "auth2"], ["auth1"]), + (["auth1", "auth2"], ["auth3"]), + (["auth1", "auth2"], ["auth3"]), + ], +) +async def test_auth_managed_groups( + app, user, group, authenticated_groups, refresh_groups +): + + authenticator = MockGroupsAuthenticator( + parent=app, + authenticated_groups=authenticated_groups, + refresh_groups=refresh_groups, + ) + + user.groups.append(group) + app.db.commit() + before_groups = [group.name] + if authenticated_groups is None: + expected_authenticated_groups = before_groups + else: + expected_authenticated_groups = authenticated_groups + if refresh_groups is None: + expected_refresh_groups = expected_authenticated_groups + else: + expected_refresh_groups = refresh_groups + + with mock.patch.dict(app.tornado_settings, {"authenticator": authenticator}): + cookies = await app.login_user(user.name) + assert not app.db.dirty + groups = sorted(g.name for g in user.groups) + assert groups == expected_authenticated_groups + + # force refresh_user on next request + user._auth_refreshed -= 10 + app.authenticator.auth_refresh_age + r = await get_page('home', app, cookies=cookies, allow_redirects=False) + assert r.status_code == 200 + assert not app.db.dirty + groups = sorted(g.name for g in user.groups) + assert groups == expected_refresh_groups diff --git a/jupyterhub/tests/test_metrics.py b/jupyterhub/tests/test_metrics.py index 29c22122..795ca89d 100644 --- a/jupyterhub/tests/test_metrics.py +++ b/jupyterhub/tests/test_metrics.py @@ -1,9 +1,13 @@ import json +from unittest import mock + +import pytest -from .utils import add_user from .utils import api_request +from .utils import get_page from jupyterhub import metrics from jupyterhub import orm +from jupyterhub import roles async def test_total_users(app): @@ -32,3 +36,42 @@ async def test_total_users(app): sample = metrics.TOTAL_USERS.collect()[0].samples[0] assert sample.value == num_users + + +@pytest.mark.parametrize( + "authenticate_prometheus, authenticated, authorized, success", + [ + (True, True, True, True), + (True, True, False, False), + (True, False, False, False), + (False, True, True, True), + (False, False, False, True), + ], +) +async def test_metrics_auth( + app, + authenticate_prometheus, + authenticated, + authorized, + success, + create_temp_role, + user, +): + if authorized: + role = create_temp_role(["read:metrics"]) + roles.grant_role(app.db, user, role) + + headers = {} + if authenticated: + token = user.new_api_token() + headers["Authorization"] = f"token {token}" + + with mock.patch.dict( + app.tornado_settings, {"authenticate_prometheus": authenticate_prometheus} + ): + r = await get_page("metrics", app, headers=headers) + if success: + assert r.status_code == 200 + else: + assert r.status_code == 403 + assert 'read:metrics' in r.text diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py index 019c805a..d5a84d4e 100644 --- a/jupyterhub/tests/test_pages.py +++ b/jupyterhub/tests/test_pages.py @@ -12,6 +12,7 @@ from tornado.escape import url_escape from tornado.httputil import url_concat from .. import orm +from .. import roles from .. import scopes from ..auth import Authenticator from ..handlers import BaseHandler @@ -20,7 +21,6 @@ from ..utils import url_path_join as ujoin from .mocking import FalsyCallableFormSpawner from .mocking import FormSpawner from .test_api import next_event -from .utils import add_user from .utils import api_request from .utils import async_requests from .utils import AsyncSession @@ -48,16 +48,16 @@ async def test_root_auth(app): # if spawning was quick, there will be one more entry that's public_url(user) -async def test_root_redirect(app): +async def test_root_redirect(app, user): name = 'wash' cookies = await app.login_user(name) - next_url = ujoin(app.base_url, 'user/other/test.ipynb') + next_url = ujoin(app.base_url, f'user/{user.name}/test.ipynb') url = '/?' + urlencode({'next': next_url}) r = await get_page(url, app, cookies=cookies) path = urlparse(r.url).path - assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name) - # serve "server not running" page, which has status 424 - assert r.status_code == 424 + assert path == ujoin(app.base_url, f'hub/user/{user.name}/test.ipynb') + # preserves choice to requested user, which 404s as unavailable without access + assert r.status_code == 404 async def test_root_default_url_noauth(app): @@ -203,13 +203,34 @@ async def test_spawn_handler_access(app): r.raise_for_status() -async def test_spawn_admin_access(app, admin_access): - """GET /user/:name as admin with admin-access spawns user's server""" - cookies = await app.login_user('admin') - name = 'mariel' - user = add_user(app.db, app=app, name=name) - app.db.commit() +@pytest.mark.parametrize("has_access", ["all", "user", "group", False]) +async def test_spawn_other_user( + app, user, username, group, create_temp_role, has_access +): + """GET /user/:name as another user with access to spawns user's server""" + cookies = await app.login_user(username) + requester = app.users[username] + name = user.name + + if has_access: + if has_access == "group": + group.users.append(user) + app.db.commit() + scopes = [ + f"access:servers!group={group.name}", + f"servers!group={group.name}", + ] + elif has_access == "all": + scopes = ["access:servers", "servers"] + elif has_access == "user": + scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"] + role = create_temp_role(scopes) + roles.grant_role(app.db, requester, role) + r = await get_page('spawn/' + name, app, cookies=cookies) + if not has_access: + assert r.status_code == 404 + return r.raise_for_status() while '/spawn-pending/' in r.url: @@ -248,14 +269,36 @@ async def test_spawn_page_falsy_callable(app): assert history[1] == ujoin(public_url(app), "hub/spawn-pending/erik") -async def test_spawn_page_admin(app, admin_access): +@pytest.mark.parametrize("has_access", ["all", "user", "group", False]) +async def test_spawn_page_access( + app, has_access, group, username, user, create_temp_role +): + cookies = await app.login_user(username) + requester = app.users[username] + if has_access: + if has_access == "group": + group.users.append(user) + app.db.commit() + scopes = [ + f"access:servers!group={group.name}", + f"servers!group={group.name}", + ] + elif has_access == "all": + scopes = ["access:servers", "servers"] + elif has_access == "user": + scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"] + role = create_temp_role(scopes) + roles.grant_role(app.db, requester, role) + with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}): - cookies = await app.login_user('admin') - u = add_user(app.db, app=app, name='melanie') - r = await get_page('spawn/' + u.name, app, cookies=cookies) - assert r.url.endswith('/spawn/' + u.name) + r = await get_page('spawn/' + user.name, app, cookies=cookies) + if not has_access: + assert r.status_code == 404 + return + assert r.status_code == 200 + assert r.url.endswith('/spawn/' + user.name) assert FormSpawner.options_form in r.text - assert f"Spawning server for {u.name}" in r.text + assert f"Spawning server for {user.name}" in r.text async def test_spawn_with_query_arguments(app): @@ -322,18 +365,39 @@ async def test_spawn_form(app): } -async def test_spawn_form_admin_access(app, admin_access): +@pytest.mark.parametrize("has_access", ["all", "user", "group", False]) +async def test_spawn_form_other_user( + app, username, user, group, create_temp_role, has_access +): + cookies = await app.login_user(username) + requester = app.users[username] + if has_access: + if has_access == "group": + group.users.append(user) + app.db.commit() + scopes = [ + f"access:servers!group={group.name}", + f"servers!group={group.name}", + ] + elif has_access == "all": + scopes = ["access:servers", "servers"] + elif has_access == "user": + scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"] + role = create_temp_role(scopes) + roles.grant_role(app.db, requester, role) + with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}): base_url = ujoin(public_host(app), app.hub.base_url) - cookies = await app.login_user('admin') - u = add_user(app.db, app=app, name='martha') - next_url = ujoin(app.base_url, 'user', u.name, 'tree') + next_url = ujoin(app.base_url, 'user', user.name, 'tree') r = await async_requests.post( - url_concat(ujoin(base_url, 'spawn', u.name), {'next': next_url}), + url_concat(ujoin(base_url, 'spawn', user.name), {'next': next_url}), cookies=cookies, data={'bounds': ['-3', '3'], 'energy': '938MeV'}, ) + if not has_access: + assert r.status_code == 404 + return r.raise_for_status() while '/spawn-pending/' in r.url: @@ -342,8 +406,8 @@ async def test_spawn_form_admin_access(app, admin_access): r.raise_for_status() assert r.history - assert r.url.startswith(public_url(app, u)) - assert u.spawner.user_options == { + assert r.url.startswith(public_url(app, user)) + assert user.spawner.user_options == { 'energy': '938MeV', 'bounds': [-3, 3], 'notspecified': 5, @@ -498,31 +562,54 @@ async def test_user_redirect_hook(app, username): assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1') -async def test_user_redirect_deprecated(app, username): - """redirecting from /user/someonelse/ URLs (deprecated)""" +@pytest.mark.parametrize("has_access", ["all", "user", "group", False]) +async def test_other_user_url(app, username, user, group, create_temp_role, has_access): + """Test accessing /user/someonelse/ URLs when the server is not running + + Used to redirect to your own server, + which produced inconsistent behavior depending on whether the server was running. + """ name = username cookies = await app.login_user(name) + other_user = user + requester = app.users[name] + other_user_url = f"/user/{other_user.name}" + if has_access: + if has_access == "group": + group.users.append(other_user) + app.db.commit() + scopes = [f"access:servers!group={group.name}"] + elif has_access == "all": + scopes = ["access:servers"] + elif has_access == "user": + scopes = [f"access:servers!user={other_user.name}"] + role = create_temp_role(scopes) + roles.grant_role(app.db, requester, role) + status = 424 + else: + # 404 - access denied without revealing if the user exists + status = 404 - r = await get_page('/user/baduser', app, cookies=cookies, hub=False) + r = await get_page(other_user_url, app, cookies=cookies, hub=False) print(urlparse(r.url)) path = urlparse(r.url).path - assert path == ujoin(app.base_url, 'hub/user/%s/' % name) - assert r.status_code == 424 + assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/') + assert r.status_code == status - r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False) + r = await get_page(f'{other_user_url}/test.ipynb', app, cookies=cookies, hub=False) print(urlparse(r.url)) path = urlparse(r.url).path - assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name) - assert r.status_code == 424 + assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/test.ipynb') + assert r.status_code == status - r = await get_page('/user/baduser/test.ipynb', app, hub=False) + r = await get_page(f'{other_user_url}/test.ipynb', app, hub=False) r.raise_for_status() print(urlparse(r.url)) path = urlparse(r.url).path assert path == ujoin(app.base_url, '/hub/login') query = urlparse(r.url).query assert query == urlencode( - {'next': ujoin(app.base_url, '/hub/user/baduser/test.ipynb')} + {'next': ujoin(app.base_url, f'/hub/user/{other_user.name}/test.ipynb')} ) @@ -1110,19 +1197,6 @@ async def test_server_not_running_api_request_legacy_status(app): assert r.status_code == 503 -async def test_metrics_no_auth(app): - r = await get_page("metrics", app) - assert r.status_code == 403 - - -async def test_metrics_auth(app): - cookies = await app.login_user('river') - metrics_url = ujoin(public_host(app), app.hub.base_url, 'metrics') - r = await get_page("metrics", app, cookies=cookies) - assert r.status_code == 200 - assert r.url == metrics_url - - async def test_health_check_request(app): r = await get_page('health', app) assert r.status_code == 200 diff --git a/jupyterhub/tests/test_spawner.py b/jupyterhub/tests/test_spawner.py index 2081abc0..1cbf398c 100644 --- a/jupyterhub/tests/test_spawner.py +++ b/jupyterhub/tests/test_spawner.py @@ -81,6 +81,18 @@ async def test_spawner(db, request): assert isinstance(status, int) +def test_spawner_from_db(app, user): + spawner = user.spawners['name'] + user_options = {"test": "value"} + spawner.orm_spawner.user_options = user_options + app.db.commit() + # delete and recreate the spawner from the db + user.spawners.pop('name') + new_spawner = user.spawners['name'] + assert new_spawner.orm_spawner.user_options == user_options + assert new_spawner.user_options == user_options + + async def wait_for_spawner(spawner, timeout=10): """Wait for an http server to show up diff --git a/jupyterhub/tests/test_user.py b/jupyterhub/tests/test_user.py index df7bc8a2..61e0270b 100644 --- a/jupyterhub/tests/test_user.py +++ b/jupyterhub/tests/test_user.py @@ -1,5 +1,6 @@ import pytest +from .. import orm from ..user import UserDict from .utils import add_user @@ -20,3 +21,35 @@ async def test_userdict_get(db, attr): assert userdict.get(key).id == u.id # `in` should find it now assert key in userdict + + +@pytest.mark.parametrize( + "group_names", + [ + ["isin1", "isin2"], + ["isin1"], + ["notin", "isin1"], + ["new-group", "isin1"], + [], + ], +) +def test_sync_groups(app, user, group_names): + expected = sorted(group_names) + db = app.db + db.add(orm.Group(name="notin")) + in_groups = [orm.Group(name="isin1"), orm.Group(name="isin2")] + for group in in_groups: + db.add(group) + db.commit() + user.groups = in_groups + db.commit() + user.sync_groups(group_names) + assert not app.db.dirty + after_groups = sorted(g.name for g in user.groups) + assert after_groups == expected + # double-check backref + for group in db.query(orm.Group): + if group.name in expected: + assert user.orm_user in group.users + else: + assert user.orm_user not in group.users diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 039f4d11..b841e22c 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -253,6 +253,42 @@ class User: def spawner_class(self): return self.settings.get('spawner_class', LocalProcessSpawner) + def sync_groups(self, group_names): + """Synchronize groups with database""" + + current_groups = {g.name for g in self.orm_user.groups} + new_groups = set(group_names) + if current_groups == new_groups: + # no change, nothing to do + return + + # log group changes + new_groups = set(group_names).difference(current_groups) + removed_groups = current_groups.difference(group_names) + if new_groups: + self.log.info("Adding user {self.name} to group(s): {new_groups}") + if removed_groups: + self.log.info("Removing user {self.name} from group(s): {removed_groups}") + + if group_names: + groups = ( + self.db.query(orm.Group).filter(orm.Group.name.in_(group_names)).all() + ) + existing_groups = {g.name for g in groups} + for group_name in group_names: + if group_name not in existing_groups: + # create groups that don't exist yet + self.log.info( + f"Creating new group {group_name} for user {self.name}" + ) + group = orm.Group(name=group_name) + self.db.add(group) + groups.append(group) + self.groups = groups + else: + self.groups = [] + self.db.commit() + async def save_auth_state(self, auth_state): """Encrypt and store auth_state""" if auth_state is None: @@ -376,6 +412,7 @@ class User: oauth_client_id=client_id, cookie_options=self.settings.get('cookie_options', {}), trusted_alt_names=trusted_alt_names, + user_options=orm_spawner.user_options or {}, ) if self.settings.get('internal_ssl'): diff --git a/jupyterhub/utils.py b/jupyterhub/utils.py index 1fc4aa89..aa3fa7f7 100644 --- a/jupyterhub/utils.py +++ b/jupyterhub/utils.py @@ -320,9 +320,11 @@ def admin_only(f): @auth_decorator def metrics_authentication(self): """Decorator for restricting access to metrics""" - user = self.current_user - if user is None and self.authenticate_prometheus: - raise web.HTTPError(403) + if not self.authenticate_prometheus: + return + scope = 'read:metrics' + if scope not in self.parsed_scopes: + raise web.HTTPError(403, f"Access to metrics requires scope '{scope}'") # Token utilities diff --git a/pyproject.toml b/pyproject.toml index 6477aaca..20d21964 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ target_version = [ github_url = "https://github.com/jupyterhub/jupyterhub" [tool.tbump.version] -current = "2.1.0.dev" +current = "2.2.0.dev" # Example of a semver regexp. # Make sure this matches current_version before diff --git a/share/jupyterhub/templates/not_running.html b/share/jupyterhub/templates/not_running.html index 0ef3ae7d..b2fcfa73 100644 --- a/share/jupyterhub/templates/not_running.html +++ b/share/jupyterhub/templates/not_running.html @@ -18,8 +18,10 @@
{% if failed %} The latest attempt to start your server {{ server_name }} has failed. - {% if failed_message %} - {{ failed_message }} + {% if failed_html_message %} +
{{ failed_html_message | safe }}
+ {% elif failed_message %} +
{{ failed_message }}
{% endif %} Would you like to retry starting it? {% else %}