mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 15:33:02 +00:00
Merge branch 'main' into group_property_feature
This commit is contained in:
@@ -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
|
||||
|
@@ -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: .
|
@@ -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.
|
||||
|
@@ -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
|
||||
|
@@ -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 = {
|
||||
|
@@ -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
|
||||
|
@@ -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.
|
||||
|
30
examples/azuread-with-group-management/jupyterhub_config.py
Normal file
30
examples/azuread-with-group-management/jupyterhub_config.py
Normal 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
|
2
examples/azuread-with-group-management/requirements.txt
Normal file
2
examples/azuread-with-group-management/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
oauthenticator
|
||||
pyjwt
|
@@ -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"
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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()
|
||||
|
||||
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)
|
||||
|
@@ -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,
|
||||
|
@@ -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(
|
||||
if get_accepted_mimetype(
|
||||
self.request.headers.get('Accept', ''),
|
||||
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)
|
||||
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)
|
||||
|
@@ -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)
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -45,6 +45,7 @@ def get_default_roles():
|
||||
'access:services',
|
||||
'access:servers',
|
||||
'read:roles',
|
||||
'read:metrics',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@@ -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.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
@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
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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'):
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -18,8 +18,10 @@
|
||||
<p>
|
||||
{% if failed %}
|
||||
The latest attempt to start your server {{ server_name }} has failed.
|
||||
{% if failed_message %}
|
||||
{{ failed_message }}
|
||||
{% if failed_html_message %}
|
||||
</p><p>{{ failed_html_message | safe }}</p><p>
|
||||
{% elif failed_message %}
|
||||
</p><p>{{ failed_message }}</p><p>
|
||||
{% endif %}
|
||||
Would you like to retry starting it?
|
||||
{% else %}
|
||||
|
Reference in New Issue
Block a user