Merge branch 'main' into group_property_feature

This commit is contained in:
Vlad Vifor
2022-02-02 11:40:50 +01:00
committed by GitHub
31 changed files with 651 additions and 143 deletions

View File

@@ -6,11 +6,11 @@ repos:
args: args:
- --py36-plus - --py36-plus
- repo: https://github.com/asottile/reorder_python_imports - repo: https://github.com/asottile/reorder_python_imports
rev: v2.6.0 rev: v2.7.1
hooks: hooks:
- id: reorder-python-imports - id: reorder-python-imports
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 21.12b0 rev: 22.1.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/pre-commit/mirrors-prettier - repo: https://github.com/pre-commit/mirrors-prettier

View File

@@ -4,10 +4,12 @@ sphinx:
configuration: docs/source/conf.py configuration: docs/source/conf.py
build: build:
image: latest os: ubuntu-20.04
tools:
nodejs: "16"
python: "3.9"
python: python:
version: 3.7
install: install:
- method: pip - method: pip
path: . path: .

View File

@@ -6,7 +6,7 @@ info:
description: The REST API for JupyterHub description: The REST API for JupyterHub
license: license:
name: BSD-3-Clause name: BSD-3-Clause
version: 2.1.0.dev version: 2.2.0.dev
servers: servers:
- url: /hub/api - url: /hub/api
security: security:
@@ -1419,3 +1419,4 @@ components:
Read information about the proxys routing table, sync the Hub Read information about the proxys routing table, sync the Hub
with the proxy and notify the Hub about a new proxy. with the proxy and notify the Hub about a new proxy.
shutdown: Shutdown the hub. shutdown: Shutdown the hub.
read:metrics: Read prometheus metrics.

View File

@@ -6,6 +6,68 @@ command line for details.
## [Unreleased] ## [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.0.2] 2022-01-10 ### [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 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.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.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 [2.0.0]: https://github.com/jupyterhub/jupyterhub/compare/1.5.0...2.0.0

View File

@@ -147,6 +147,13 @@ html_theme_options = {
"navbar_align": "left", "navbar_align": "left",
} }
html_context = {
"github_user": "jupyterhub",
"github_repo": "jupyterhub",
"github_version": "main",
"doc_path": "docs",
}
# -- Options for LaTeX output --------------------------------------------- # -- Options for LaTeX output ---------------------------------------------
latex_elements = { latex_elements = {

View File

@@ -247,6 +247,36 @@ class MyAuthenticator(Authenticator):
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token'] 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 ## pre_spawn_start and post_spawn_stop hooks
Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and

View File

@@ -108,6 +108,16 @@ class MySpawner(Spawner):
return url 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
`Spawner.poll` should check if the spawner is still running. `Spawner.poll` should check if the spawner is still running.

View File

@@ -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

View File

@@ -0,0 +1,2 @@
oauthenticator
pyjwt

View File

@@ -3664,9 +3664,9 @@ flatted@^3.1.0:
integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
follow-redirects@^1.0.0: follow-redirects@^1.0.0:
version "1.13.0" version "1.14.7"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
for-in@^1.0.2: for-in@^1.0.2:
version "1.0.2" version "1.0.2"
@@ -5636,9 +5636,9 @@ nan@^2.12.1:
integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ== integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
nanoid@^3.1.23: nanoid@^3.1.23:
version "3.1.23" version "3.2.0"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.23.tgz#f744086ce7c2bc47ee0a8472574d5c78e4183a81" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c"
integrity sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw== integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA==
nanomatch@^1.2.9: nanomatch@^1.2.9:
version "1.2.13" version "1.2.13"

View File

@@ -2,7 +2,7 @@
# Copyright (c) Jupyter Development Team. # Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License. # Distributed under the terms of the Modified BSD License.
# version_info updated by running `tbump` # 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 # pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1 # 0.1.0rc1

View File

@@ -33,6 +33,11 @@ class _GroupAPIHandler(APIHandler):
raise web.HTTPError(404, "No such group: %s", group_name) raise web.HTTPError(404, "No such group: %s", group_name)
return group 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): class GroupListAPIHandler(_GroupAPIHandler):
@needs_scope('list:groups') @needs_scope('list:groups')
@@ -68,6 +73,9 @@ class GroupListAPIHandler(_GroupAPIHandler):
@needs_scope('admin:groups') @needs_scope('admin:groups')
async def post(self): async def post(self):
"""POST creates Multiple groups""" """POST creates Multiple groups"""
self.check_authenticator_managed_groups()
model = self.get_json_body() model = self.get_json_body()
if not model or not isinstance(model, dict) or not model.get('groups'): 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") raise web.HTTPError(400, "Must specify at least one group to create")
@@ -106,6 +114,7 @@ class GroupAPIHandler(_GroupAPIHandler):
@needs_scope('admin:groups') @needs_scope('admin:groups')
async def post(self, group_name): async def post(self, group_name):
"""POST creates a group by name""" """POST creates a group by name"""
self.check_authenticator_managed_groups()
model = self.get_json_body() model = self.get_json_body()
if model is None: if model is None:
model = {} model = {}
@@ -132,6 +141,7 @@ class GroupAPIHandler(_GroupAPIHandler):
@needs_scope('delete:groups') @needs_scope('delete:groups')
def delete(self, group_name): def delete(self, group_name):
"""Delete a group by name""" """Delete a group by name"""
self.check_authenticator_managed_groups()
group = self.find_group(group_name) group = self.find_group(group_name)
self.log.info("Deleting group %s", group_name) self.log.info("Deleting group %s", group_name)
self.db.delete(group) self.db.delete(group)
@@ -145,6 +155,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
@needs_scope('groups') @needs_scope('groups')
def post(self, group_name): def post(self, group_name):
"""POST adds users to a group""" """POST adds users to a group"""
self.check_authenticator_managed_groups()
group = self.find_group(group_name) group = self.find_group(group_name)
data = self.get_json_body() data = self.get_json_body()
self._check_group_model(data) self._check_group_model(data)
@@ -163,6 +174,7 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
@needs_scope('groups') @needs_scope('groups')
async def delete(self, group_name): async def delete(self, group_name):
"""DELETE removes users from a group""" """DELETE removes users from a group"""
self.check_authenticator_managed_groups()
group = self.find_group(group_name) group = self.find_group(group_name)
data = self.get_json_body() data = self.get_json_body()
self._check_group_model(data) self._check_group_model(data)

View File

@@ -714,7 +714,12 @@ class SpawnProgressAPIHandler(APIHandler):
# check if spawner has just failed # check if spawner has just failed
f = spawn_future f = spawn_future
if f and f.done() and f.exception(): 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) await self.send_event(failed_event)
return return
else: else:
@@ -747,7 +752,12 @@ class SpawnProgressAPIHandler(APIHandler):
# what happened? Maybe spawn failed? # what happened? Maybe spawn failed?
f = spawn_future f = spawn_future
if f and f.done() and f.exception(): 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: else:
self.log.warning( self.log.warning(
"Server %s didn't start for unknown reason", spawner._log_name "Server %s didn't start for unknown reason", spawner._log_name

View File

@@ -2001,6 +2001,9 @@ class JupyterHub(Application):
async def init_groups(self): async def init_groups(self):
"""Load predefined groups into the database""" """Load predefined groups into the database"""
db = self.db 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(): for name, usernames in self.load_groups.items():
group = orm.Group.find(db, name) group = orm.Group.find(db, name)
if group is None: if group is None:
@@ -3147,7 +3150,12 @@ class JupyterHub(Application):
self.last_activity_callback = pc self.last_activity_callback = pc
pc.start() 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 # Use atexit for Windows, it doesn't have signal handling support
if _mswindows: if _mswindows:
atexit.register(self.atexit) atexit.register(self.atexit)

View File

@@ -582,9 +582,13 @@ class Authenticator(LoggingConfigurable):
or None if Authentication failed. or None if Authentication failed.
The Authenticator may return a dict instead, which MUST have a The Authenticator may return a dict instead, which MUST have a
key `name` holding the username, and MAY have two optional keys key `name` holding the username, and MAY have additional keys:
set: `auth_state`, a dictionary of of auth state that will be
persisted; and `admin`, the admin setting value for the user. - `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): def pre_spawn_start(self, user, spawner):
@@ -635,6 +639,19 @@ class Authenticator(LoggingConfigurable):
""" """
self.allowed_users.discard(user.name) 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( auto_login = Bool(
False, False,
config=True, config=True,

View File

@@ -45,6 +45,7 @@ from ..metrics import ServerSpawnStatus
from ..metrics import ServerStopStatus from ..metrics import ServerStopStatus
from ..metrics import TOTAL_USERS from ..metrics import TOTAL_USERS
from ..objects import Server from ..objects import Server
from ..scopes import needs_scope
from ..spawner import LocalProcessSpawner from ..spawner import LocalProcessSpawner
from ..user import User from ..user import User
from ..utils import AnyTimeoutError from ..utils import AnyTimeoutError
@@ -773,13 +774,22 @@ class BaseHandler(RequestHandler):
# always ensure default roles ('user', 'admin' if admin) are assigned # always ensure default roles ('user', 'admin' if admin) are assigned
# after a successful login # after a successful login
roles.assign_default_roles(self.db, entity=user) 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, # always set auth_state and commit,
# because there could be key-rotation or clearing of previous values # because there could be key-rotation or clearing of previous values
# going on. # going on.
if not self.authenticator.enable_auth_state: if not self.authenticator.enable_auth_state:
# auth_state is not enabled. Force None. # auth_state is not enabled. Force None.
auth_state = None auth_state = None
await user.save_auth_state(auth_state) await user.save_auth_state(auth_state)
return user return user
async def login_user(self, data=None): async def login_user(self, data=None):
@@ -793,6 +803,7 @@ class BaseHandler(RequestHandler):
self.set_login_cookie(user) self.set_login_cookie(user)
self.statsd.incr('login.success') self.statsd.incr('login.success')
self.statsd.timing('login.authenticate.success', auth_timer.ms) self.statsd.timing('login.authenticate.success', auth_timer.ms)
self.log.info("User logged in: %s", user.name) self.log.info("User logged in: %s", user.name)
user._auth_refreshed = time.monotonic() user._auth_refreshed = time.monotonic()
return user return user
@@ -1448,54 +1459,24 @@ class UserUrlHandler(BaseHandler):
delete = non_get delete = non_get
@web.authenticated @web.authenticated
@needs_scope("access:servers")
async def get(self, user_name, user_path): async def get(self, user_name, user_path):
if not user_path: if not user_path:
user_path = '/' user_path = '/'
current_user = self.current_user current_user = self.current_user
if user_name != current_user.name:
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
user = self.find_user(user_name) user = self.find_user(user_name)
if user is None: if user is None:
# no such user # 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( self.log.info(
"Admin %s requesting spawn on behalf of %s", f"User {current_user.name} requesting spawn on behalf of {user.name}"
current_user.name,
user.name,
) )
admin_spawn = True admin_spawn = True
should_spawn = True should_spawn = True
redirect_to_self = False redirect_to_self = False
else: else:
user = current_user 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, # If people visit /user/:user_name directly on the Hub,
# the redirects will just loop, because the proxy is bypassed. # 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 # 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 # because it won't like the redirect to the pending page
if ( if get_accepted_mimetype(
get_accepted_mimetype( self.request.headers.get('Accept', ''),
self.request.headers.get('Accept', ''), choices=['application/json', 'text/html'],
choices=['application/json', 'text/html'], ) == 'application/json' or 'api' in user_path.split('/'):
)
== 'application/json'
or 'api' in user_path.split('/')
):
self._fail_api_request(user_name, server_name) self._fail_api_request(user_name, server_name)
return return
@@ -1628,7 +1605,7 @@ class UserUrlHandler(BaseHandler):
if redirects: if redirects:
self.log.warning("Redirect loop detected on %s", self.request.uri) self.log.warning("Redirect loop detected on %s", self.request.uri)
# add capped exponential backoff where cap is 10s # 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 # rewrite target url with new `redirects` query value
url_parts = urlparse(target) url_parts = urlparse(target)
query_parts = parse_qs(url_parts.query) query_parts = parse_qs(url_parts.query)

View File

@@ -12,6 +12,8 @@ class MetricsHandler(BaseHandler):
Handler to serve Prometheus metrics Handler to serve Prometheus metrics
""" """
_accept_token_auth = True
@metrics_authentication @metrics_authentication
async def get(self): async def get(self):
self.set_header('Content-Type', CONTENT_TYPE_LATEST) self.set_header('Content-Type', CONTENT_TYPE_LATEST)

View File

@@ -106,22 +106,27 @@ class SpawnHandler(BaseHandler):
) )
@web.authenticated @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 """GET renders form for spawning with user-specified options
or triggers spawn via redirect if there is no form. 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 user = current_user = self.current_user
if for_user is not None and for_user != user.name: if for_user != user.name:
if not user.admin:
raise web.HTTPError(
403, "Only admins can spawn on behalf of other users"
)
user = self.find_user(for_user) user = self.find_user(for_user)
if user is None: 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 server_name:
if not self.allow_named_servers: if not self.allow_named_servers:
@@ -141,14 +146,11 @@ class SpawnHandler(BaseHandler):
) )
if not self.allow_named_servers and user.running: 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.log.info("User is running: %s", user.name)
self.redirect(url) self.redirect(url)
return return
if server_name is None:
server_name = ''
spawner = user.spawners[server_name] spawner = user.spawners[server_name]
pending_url = self._get_pending_url(user, server_name) pending_url = self._get_pending_url(user, server_name)
@@ -189,7 +191,6 @@ class SpawnHandler(BaseHandler):
spawner._log_name, spawner._log_name,
) )
options = await maybe_future(spawner.options_from_query(query_options)) 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( return await self._wrap_spawn_single_user(
user, server_name, spawner, pending_url, options user, server_name, spawner, pending_url, options
) )
@@ -219,14 +220,19 @@ class SpawnHandler(BaseHandler):
) )
@web.authenticated @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""" """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 user = current_user = self.current_user
if for_user is not None and for_user != user.name: if for_user != user.name:
if not user.admin:
raise web.HTTPError(
403, "Only admins can spawn on behalf of other users"
)
user = self.find_user(for_user) user = self.find_user(for_user)
if user is None: if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user) raise web.HTTPError(404, "No such user: %s" % for_user)
@@ -337,13 +343,11 @@ class SpawnPendingHandler(BaseHandler):
""" """
@web.authenticated @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 user = current_user = self.current_user
if for_user is not None and for_user != current_user.name: if for_user != current_user.name:
if not current_user.admin:
raise web.HTTPError(
403, "Only admins can spawn on behalf of other users"
)
user = self.find_user(for_user) user = self.find_user(for_user)
if user is None: if user is None:
raise web.HTTPError(404, "No such user: %s" % for_user) raise web.HTTPError(404, "No such user: %s" % for_user)
@@ -387,6 +391,7 @@ class SpawnPendingHandler(BaseHandler):
server_name=server_name, server_name=server_name,
spawn_url=spawn_url, spawn_url=spawn_url,
failed=True, failed=True,
failed_html_message=getattr(exc, 'jupyterhub_html_message', ''),
failed_message=getattr(exc, 'jupyterhub_message', ''), failed_message=getattr(exc, 'jupyterhub_message', ''),
exception=exc, exception=exc,
) )

View File

@@ -45,6 +45,7 @@ def get_default_roles():
'access:services', 'access:services',
'access:servers', 'access:servers',
'read:roles', 'read:roles',
'read:metrics',
], ],
}, },
{ {

View File

@@ -131,6 +131,9 @@ scope_definitions = {
'description': 'Read information about the proxys routing table, sync the Hub with the proxy and notify the Hub about a new proxy.' 'description': 'Read information about the proxys routing table, sync the Hub with the proxy and notify the Hub about a new proxy.'
}, },
'shutdown': {'description': 'Shutdown the hub.'}, 'shutdown': {'description': 'Shutdown the hub.'},
'read:metrics': {
'description': "Read prometheus metrics.",
},
} }

View File

@@ -493,7 +493,7 @@ class SingleUserNotebookAppMixin(Configurable):
i, i,
RETRIES, RETRIES,
) )
await asyncio.sleep(min(2 ** i, 16)) await asyncio.sleep(min(2**i, 16))
else: else:
break break
else: else:

View File

@@ -1843,6 +1843,61 @@ async def test_group_add_delete_users(app):
assert sorted(u.name for u in group.users) == sorted(names[2:]) 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 # Service API tests

View File

@@ -7,6 +7,7 @@ from urllib.parse import urlparse
import pytest import pytest
from requests import HTTPError from requests import HTTPError
from traitlets import Any
from traitlets.config import Config from traitlets.config import Config
from .mocking import MockPAMAuthenticator from .mocking import MockPAMAuthenticator
@@ -14,6 +15,7 @@ from .mocking import MockStructGroup
from .mocking import MockStructPasswd from .mocking import MockStructPasswd
from .utils import add_user from .utils import add_user
from .utils import async_requests from .utils import async_requests
from .utils import get_page
from .utils import public_url from .utils import public_url
from jupyterhub import auth from jupyterhub import auth
from jupyterhub import crypto from jupyterhub import crypto
@@ -527,3 +529,71 @@ async def test_nullauthenticator(app):
r = await async_requests.get(public_url(app)) r = await async_requests.get(public_url(app))
assert urlparse(r.url).path.endswith("/hub/login") assert urlparse(r.url).path.endswith("/hub/login")
assert r.status_code == 403 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

View File

@@ -1,9 +1,13 @@
import json import json
from unittest import mock
import pytest
from .utils import add_user
from .utils import api_request from .utils import api_request
from .utils import get_page
from jupyterhub import metrics from jupyterhub import metrics
from jupyterhub import orm from jupyterhub import orm
from jupyterhub import roles
async def test_total_users(app): async def test_total_users(app):
@@ -32,3 +36,42 @@ async def test_total_users(app):
sample = metrics.TOTAL_USERS.collect()[0].samples[0] sample = metrics.TOTAL_USERS.collect()[0].samples[0]
assert sample.value == num_users 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

View File

@@ -12,6 +12,7 @@ from tornado.escape import url_escape
from tornado.httputil import url_concat from tornado.httputil import url_concat
from .. import orm from .. import orm
from .. import roles
from .. import scopes from .. import scopes
from ..auth import Authenticator from ..auth import Authenticator
from ..handlers import BaseHandler from ..handlers import BaseHandler
@@ -20,7 +21,6 @@ from ..utils import url_path_join as ujoin
from .mocking import FalsyCallableFormSpawner from .mocking import FalsyCallableFormSpawner
from .mocking import FormSpawner from .mocking import FormSpawner
from .test_api import next_event from .test_api import next_event
from .utils import add_user
from .utils import api_request from .utils import api_request
from .utils import async_requests from .utils import async_requests
from .utils import AsyncSession 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) # 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' name = 'wash'
cookies = await app.login_user(name) 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}) url = '/?' + urlencode({'next': next_url})
r = await get_page(url, app, cookies=cookies) r = await get_page(url, app, cookies=cookies)
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name) assert path == ujoin(app.base_url, f'hub/user/{user.name}/test.ipynb')
# serve "server not running" page, which has status 424 # preserves choice to requested user, which 404s as unavailable without access
assert r.status_code == 424 assert r.status_code == 404
async def test_root_default_url_noauth(app): async def test_root_default_url_noauth(app):
@@ -203,13 +203,34 @@ async def test_spawn_handler_access(app):
r.raise_for_status() r.raise_for_status()
async def test_spawn_admin_access(app, admin_access): @pytest.mark.parametrize("has_access", ["all", "user", "group", False])
"""GET /user/:name as admin with admin-access spawns user's server""" async def test_spawn_other_user(
cookies = await app.login_user('admin') app, user, username, group, create_temp_role, has_access
name = 'mariel' ):
user = add_user(app.db, app=app, name=name) """GET /user/:name as another user with access to spawns user's server"""
app.db.commit() 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) r = await get_page('spawn/' + name, app, cookies=cookies)
if not has_access:
assert r.status_code == 404
return
r.raise_for_status() r.raise_for_status()
while '/spawn-pending/' in r.url: 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") 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}): with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
cookies = await app.login_user('admin') r = await get_page('spawn/' + user.name, app, cookies=cookies)
u = add_user(app.db, app=app, name='melanie') if not has_access:
r = await get_page('spawn/' + u.name, app, cookies=cookies) assert r.status_code == 404
assert r.url.endswith('/spawn/' + u.name) return
assert r.status_code == 200
assert r.url.endswith('/spawn/' + user.name)
assert FormSpawner.options_form in r.text 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): 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}): with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_host(app), app.hub.base_url) base_url = ujoin(public_host(app), app.hub.base_url)
cookies = await app.login_user('admin') next_url = ujoin(app.base_url, 'user', user.name, 'tree')
u = add_user(app.db, app=app, name='martha')
next_url = ujoin(app.base_url, 'user', u.name, 'tree')
r = await async_requests.post( 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, cookies=cookies,
data={'bounds': ['-3', '3'], 'energy': '938MeV'}, data={'bounds': ['-3', '3'], 'energy': '938MeV'},
) )
if not has_access:
assert r.status_code == 404
return
r.raise_for_status() r.raise_for_status()
while '/spawn-pending/' in r.url: while '/spawn-pending/' in r.url:
@@ -342,8 +406,8 @@ async def test_spawn_form_admin_access(app, admin_access):
r.raise_for_status() r.raise_for_status()
assert r.history assert r.history
assert r.url.startswith(public_url(app, u)) assert r.url.startswith(public_url(app, user))
assert u.spawner.user_options == { assert user.spawner.user_options == {
'energy': '938MeV', 'energy': '938MeV',
'bounds': [-3, 3], 'bounds': [-3, 3],
'notspecified': 5, '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') assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1')
async def test_user_redirect_deprecated(app, username): @pytest.mark.parametrize("has_access", ["all", "user", "group", False])
"""redirecting from /user/someonelse/ URLs (deprecated)""" 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 name = username
cookies = await app.login_user(name) 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)) print(urlparse(r.url))
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/' % name) assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/')
assert r.status_code == 424 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)) print(urlparse(r.url))
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name) assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/test.ipynb')
assert r.status_code == 424 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() r.raise_for_status()
print(urlparse(r.url)) print(urlparse(r.url))
path = urlparse(r.url).path path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/hub/login') assert path == ujoin(app.base_url, '/hub/login')
query = urlparse(r.url).query query = urlparse(r.url).query
assert query == urlencode( 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 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): async def test_health_check_request(app):
r = await get_page('health', app) r = await get_page('health', app)
assert r.status_code == 200 assert r.status_code == 200

View File

@@ -81,6 +81,18 @@ async def test_spawner(db, request):
assert isinstance(status, int) 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): async def wait_for_spawner(spawner, timeout=10):
"""Wait for an http server to show up """Wait for an http server to show up

View File

@@ -1,5 +1,6 @@
import pytest import pytest
from .. import orm
from ..user import UserDict from ..user import UserDict
from .utils import add_user from .utils import add_user
@@ -20,3 +21,35 @@ async def test_userdict_get(db, attr):
assert userdict.get(key).id == u.id assert userdict.get(key).id == u.id
# `in` should find it now # `in` should find it now
assert key in userdict 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

View File

@@ -253,6 +253,42 @@ class User:
def spawner_class(self): def spawner_class(self):
return self.settings.get('spawner_class', LocalProcessSpawner) 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): async def save_auth_state(self, auth_state):
"""Encrypt and store auth_state""" """Encrypt and store auth_state"""
if auth_state is None: if auth_state is None:
@@ -376,6 +412,7 @@ class User:
oauth_client_id=client_id, oauth_client_id=client_id,
cookie_options=self.settings.get('cookie_options', {}), cookie_options=self.settings.get('cookie_options', {}),
trusted_alt_names=trusted_alt_names, trusted_alt_names=trusted_alt_names,
user_options=orm_spawner.user_options or {},
) )
if self.settings.get('internal_ssl'): if self.settings.get('internal_ssl'):

View File

@@ -320,9 +320,11 @@ def admin_only(f):
@auth_decorator @auth_decorator
def metrics_authentication(self): def metrics_authentication(self):
"""Decorator for restricting access to metrics""" """Decorator for restricting access to metrics"""
user = self.current_user if not self.authenticate_prometheus:
if user is None and self.authenticate_prometheus: return
raise web.HTTPError(403) scope = 'read:metrics'
if scope not in self.parsed_scopes:
raise web.HTTPError(403, f"Access to metrics requires scope '{scope}'")
# Token utilities # Token utilities

View File

@@ -11,7 +11,7 @@ target_version = [
github_url = "https://github.com/jupyterhub/jupyterhub" github_url = "https://github.com/jupyterhub/jupyterhub"
[tool.tbump.version] [tool.tbump.version]
current = "2.1.0.dev" current = "2.2.0.dev"
# Example of a semver regexp. # Example of a semver regexp.
# Make sure this matches current_version before # Make sure this matches current_version before

View File

@@ -18,8 +18,10 @@
<p> <p>
{% if failed %} {% if failed %}
The latest attempt to start your server {{ server_name }} has failed. The latest attempt to start your server {{ server_name }} has failed.
{% if failed_message %} {% if failed_html_message %}
{{ failed_message }} </p><p>{{ failed_html_message | safe }}</p><p>
{% elif failed_message %}
</p><p>{{ failed_message }}</p><p>
{% endif %} {% endif %}
Would you like to retry starting it? Would you like to retry starting it?
{% else %} {% else %}