Compare commits

...

24 Commits
2.0.2 ... 2.1.0

Author SHA1 Message Date
Min RK
b8dc3befab Bump to 2.1.0 2022-01-21 11:35:49 +01:00
Erik Sundell
2f29848757 Merge pull request #3776 from minrk/cl21
Changelog for 2.1.0
2022-01-21 10:54:09 +01:00
Min RK
4f3d6cdd0c changelog for 2.1.0 2022-01-21 10:42:25 +01:00
Min RK
67733ef928 Merge pull request #3773 from IgorBerman/issue-3772-user_options-returns-empty-jupyterhub-restart
Using orm_spawner in server model user_options
2022-01-21 09:38:37 +01:00
Erik Sundell
e657754e7f Merge pull request #3775 from minrk/on_rtd_edit
DOCS: Add github metadata for edit button
2022-01-20 19:39:35 +01:00
Igor Berman
2d6087959c issue-3772: populating user_options from orm_spawner; adding test 2022-01-20 20:07:43 +02:00
Min RK
08a913707f define html_context needed for edit_page_button 2022-01-20 18:56:41 +01:00
Igor Berman
9c8a4f287a issue-3772: populating user_options from orm_spawner, cleanup 2022-01-20 18:04:35 +02:00
Igor Berman
64d6f0222c issue-3772: populating user_options from orm_spawner 2022-01-20 18:01:57 +02:00
Erik Sundell
538abdf084 Merge pull request #3763 from minrk/page-scopes
apply scope checks to some admin-or-self situations
2022-01-20 16:21:51 +01:00
Min RK
6e5c307edb apply scope checks to some admin-or-self pages
Some non-api spawn and redirect checks still had `self or admin`,
when they should have checked directly for the appropriate permissions

This removes the long-deprecated redirect from `/user/other` -> `/user/self` _if_ the other server is not running.
The result is a more consistent behavior whether the requested server is running or not,
and whether the user has _access_ to the running server or not.
2022-01-20 13:27:43 +01:00
Igor Berman
67ebe0b0cf Update base.py 2022-01-19 21:45:45 +02:00
Min RK
dcf21d53fd Merge pull request #3765 from twalcari/patch-2
Improve documentation about spawner exception handling
2022-01-19 10:01:51 +01:00
Erik Sundell
f5bb0a2622 Merge pull request #3770 from minrk/metrics-scope
Add `read:metrics` scope for metrics endpoint
2022-01-18 17:51:50 +01:00
Min RK
704712cc81 Add read:metrics scope for metrics endpoint
and ensure token auth is accepted
2022-01-18 15:02:24 +01:00
Erik Sundell
f86d53a234 Merge pull request #3764 from minrk/progress-error-message
relay custom messages in exception.jupyterhub_message in progress API
2022-01-18 13:18:29 +01:00
Thijs Walcarius
5466224988 Improve documentation about spawner error messages 2022-01-18 09:18:01 +01:00
Min RK
f9fa21bfd7 relay custom messages in exception.jupyterhub_message in progress API
matches the message shown on the HTML spawn-failed page

For consistency, also support `jupyterhub_html_message` to populate the `html_message` field
2022-01-18 09:15:58 +01:00
Simon Li
e4855c30f5 Merge pull request #3768 from jupyterhub/dependabot/npm_and_yarn/jsx/follow-redirects-1.14.7
Bump follow-redirects from 1.13.0 to 1.14.7 in /jsx
2022-01-15 13:56:47 +00:00
dependabot[bot]
f1c4fdd5a2 Bump follow-redirects from 1.13.0 to 1.14.7 in /jsx
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.13.0 to 1.14.7.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.13.0...v1.14.7)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-15 08:58:31 +00:00
Min RK
e58cf06706 Merge pull request #3762 from DougTrajano/main
Add the capability to inform a connection to Alembic Migration Script
2022-01-12 14:02:09 +01:00
pre-commit-ci[bot]
91f4918cff [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-11 11:55:37 +00:00
Douglas Trajano
b15ccfa4ae Add connection parameter 2022-01-11 08:50:20 -03:00
Min RK
5102fde2f0 Bump to 2.1.0.dev 2022-01-10 13:54:49 +01:00
19 changed files with 328 additions and 129 deletions

View File

@@ -6,7 +6,7 @@ info:
description: The REST API for JupyterHub
license:
name: BSD-3-Clause
version: 2.0.2
version: 2.1.0
servers:
- url: /hub/api
security:
@@ -1419,3 +1419,4 @@ components:
Read information about the proxys 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.

View File

@@ -6,6 +6,51 @@ command line for details.
## [Unreleased]
## 2.1
### 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 +1434,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

View File

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

View File

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

View File

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

View File

@@ -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, 0, 2, "", "")
version_info = (2, 1, 0, "", "")
# pep 440 version: no dot before beta/rc, but before .dev
# 0.1.0rc1

View File

@@ -55,8 +55,15 @@ def run_migrations_offline():
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
connectable = config.attributes.get('connection', None)
if connectable is None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
else:
context.configure(
connection=connectable, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
@@ -69,11 +76,14 @@ def run_migrations_online():
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
connectable = config.attributes.get('connection', None)
if connectable is None:
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)

View File

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

View File

@@ -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
@@ -1448,54 +1449,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.

View File

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

View File

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

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.'
},
'shutdown': {'description': 'Shutdown the hub.'},
'read:metrics': {
'description': "Read prometheus metrics.",
},
}

View File

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

View File

@@ -12,6 +12,7 @@ from tornado.escape import url_escape
from tornado.httputil import url_concat
from .. import orm
from .. import roles
from .. import scopes
from ..auth import Authenticator
from ..handlers import BaseHandler
@@ -20,7 +21,6 @@ from ..utils import url_path_join as ujoin
from .mocking import FalsyCallableFormSpawner
from .mocking import FormSpawner
from .test_api import next_event
from .utils import add_user
from .utils import api_request
from .utils import async_requests
from .utils import AsyncSession
@@ -48,16 +48,16 @@ async def test_root_auth(app):
# if spawning was quick, there will be one more entry that's public_url(user)
async def test_root_redirect(app):
async def test_root_redirect(app, user):
name = 'wash'
cookies = await app.login_user(name)
next_url = ujoin(app.base_url, 'user/other/test.ipynb')
next_url = ujoin(app.base_url, f'user/{user.name}/test.ipynb')
url = '/?' + urlencode({'next': next_url})
r = await get_page(url, app, cookies=cookies)
path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
# serve "server not running" page, which has status 424
assert r.status_code == 424
assert path == ujoin(app.base_url, f'hub/user/{user.name}/test.ipynb')
# preserves choice to requested user, which 404s as unavailable without access
assert r.status_code == 404
async def test_root_default_url_noauth(app):
@@ -203,13 +203,34 @@ async def test_spawn_handler_access(app):
r.raise_for_status()
async def test_spawn_admin_access(app, admin_access):
"""GET /user/:name as admin with admin-access spawns user's server"""
cookies = await app.login_user('admin')
name = 'mariel'
user = add_user(app.db, app=app, name=name)
app.db.commit()
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
async def test_spawn_other_user(
app, user, username, group, create_temp_role, has_access
):
"""GET /user/:name as another user with access to spawns user's server"""
cookies = await app.login_user(username)
requester = app.users[username]
name = user.name
if has_access:
if has_access == "group":
group.users.append(user)
app.db.commit()
scopes = [
f"access:servers!group={group.name}",
f"servers!group={group.name}",
]
elif has_access == "all":
scopes = ["access:servers", "servers"]
elif has_access == "user":
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
role = create_temp_role(scopes)
roles.grant_role(app.db, requester, role)
r = await get_page('spawn/' + name, app, cookies=cookies)
if not has_access:
assert r.status_code == 404
return
r.raise_for_status()
while '/spawn-pending/' in r.url:
@@ -248,14 +269,36 @@ async def test_spawn_page_falsy_callable(app):
assert history[1] == ujoin(public_url(app), "hub/spawn-pending/erik")
async def test_spawn_page_admin(app, admin_access):
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
async def test_spawn_page_access(
app, has_access, group, username, user, create_temp_role
):
cookies = await app.login_user(username)
requester = app.users[username]
if has_access:
if has_access == "group":
group.users.append(user)
app.db.commit()
scopes = [
f"access:servers!group={group.name}",
f"servers!group={group.name}",
]
elif has_access == "all":
scopes = ["access:servers", "servers"]
elif has_access == "user":
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
role = create_temp_role(scopes)
roles.grant_role(app.db, requester, role)
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
cookies = await app.login_user('admin')
u = add_user(app.db, app=app, name='melanie')
r = await get_page('spawn/' + u.name, app, cookies=cookies)
assert r.url.endswith('/spawn/' + u.name)
r = await get_page('spawn/' + user.name, app, cookies=cookies)
if not has_access:
assert r.status_code == 404
return
assert r.status_code == 200
assert r.url.endswith('/spawn/' + user.name)
assert FormSpawner.options_form in r.text
assert f"Spawning server for {u.name}" in r.text
assert f"Spawning server for {user.name}" in r.text
async def test_spawn_with_query_arguments(app):
@@ -322,18 +365,39 @@ async def test_spawn_form(app):
}
async def test_spawn_form_admin_access(app, admin_access):
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
async def test_spawn_form_other_user(
app, username, user, group, create_temp_role, has_access
):
cookies = await app.login_user(username)
requester = app.users[username]
if has_access:
if has_access == "group":
group.users.append(user)
app.db.commit()
scopes = [
f"access:servers!group={group.name}",
f"servers!group={group.name}",
]
elif has_access == "all":
scopes = ["access:servers", "servers"]
elif has_access == "user":
scopes = [f"access:servers!user={user.name}", f"servers!user={user.name}"]
role = create_temp_role(scopes)
roles.grant_role(app.db, requester, role)
with mock.patch.dict(app.tornado_settings, {'spawner_class': FormSpawner}):
base_url = ujoin(public_host(app), app.hub.base_url)
cookies = await app.login_user('admin')
u = add_user(app.db, app=app, name='martha')
next_url = ujoin(app.base_url, 'user', u.name, 'tree')
next_url = ujoin(app.base_url, 'user', user.name, 'tree')
r = await async_requests.post(
url_concat(ujoin(base_url, 'spawn', u.name), {'next': next_url}),
url_concat(ujoin(base_url, 'spawn', user.name), {'next': next_url}),
cookies=cookies,
data={'bounds': ['-3', '3'], 'energy': '938MeV'},
)
if not has_access:
assert r.status_code == 404
return
r.raise_for_status()
while '/spawn-pending/' in r.url:
@@ -342,8 +406,8 @@ async def test_spawn_form_admin_access(app, admin_access):
r.raise_for_status()
assert r.history
assert r.url.startswith(public_url(app, u))
assert u.spawner.user_options == {
assert r.url.startswith(public_url(app, user))
assert user.spawner.user_options == {
'energy': '938MeV',
'bounds': [-3, 3],
'notspecified': 5,
@@ -498,31 +562,54 @@ async def test_user_redirect_hook(app, username):
assert redirected_url.path == ujoin(app.base_url, 'user', username, 'terminals/1')
async def test_user_redirect_deprecated(app, username):
"""redirecting from /user/someonelse/ URLs (deprecated)"""
@pytest.mark.parametrize("has_access", ["all", "user", "group", False])
async def test_other_user_url(app, username, user, group, create_temp_role, has_access):
"""Test accessing /user/someonelse/ URLs when the server is not running
Used to redirect to your own server,
which produced inconsistent behavior depending on whether the server was running.
"""
name = username
cookies = await app.login_user(name)
other_user = user
requester = app.users[name]
other_user_url = f"/user/{other_user.name}"
if has_access:
if has_access == "group":
group.users.append(other_user)
app.db.commit()
scopes = [f"access:servers!group={group.name}"]
elif has_access == "all":
scopes = ["access:servers"]
elif has_access == "user":
scopes = [f"access:servers!user={other_user.name}"]
role = create_temp_role(scopes)
roles.grant_role(app.db, requester, role)
status = 424
else:
# 404 - access denied without revealing if the user exists
status = 404
r = await get_page('/user/baduser', app, cookies=cookies, hub=False)
r = await get_page(other_user_url, app, cookies=cookies, hub=False)
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/' % name)
assert r.status_code == 424
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/')
assert r.status_code == status
r = await get_page('/user/baduser/test.ipynb', app, cookies=cookies, hub=False)
r = await get_page(f'{other_user_url}/test.ipynb', app, cookies=cookies, hub=False)
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == ujoin(app.base_url, 'hub/user/%s/test.ipynb' % name)
assert r.status_code == 424
assert path == ujoin(app.base_url, f'hub/user/{other_user.name}/test.ipynb')
assert r.status_code == status
r = await get_page('/user/baduser/test.ipynb', app, hub=False)
r = await get_page(f'{other_user_url}/test.ipynb', app, hub=False)
r.raise_for_status()
print(urlparse(r.url))
path = urlparse(r.url).path
assert path == ujoin(app.base_url, '/hub/login')
query = urlparse(r.url).query
assert query == urlencode(
{'next': ujoin(app.base_url, '/hub/user/baduser/test.ipynb')}
{'next': ujoin(app.base_url, f'/hub/user/{other_user.name}/test.ipynb')}
)
@@ -1110,19 +1197,6 @@ async def test_server_not_running_api_request_legacy_status(app):
assert r.status_code == 503
async def test_metrics_no_auth(app):
r = await get_page("metrics", app)
assert r.status_code == 403
async def test_metrics_auth(app):
cookies = await app.login_user('river')
metrics_url = ujoin(public_host(app), app.hub.base_url, 'metrics')
r = await get_page("metrics", app, cookies=cookies)
assert r.status_code == 200
assert r.url == metrics_url
async def test_health_check_request(app):
r = await get_page('health', app)
assert r.status_code == 200

View File

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

View File

@@ -376,6 +376,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'):

View File

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

View File

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

View File

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