Compare commits

...

54 Commits
2.0.1 ... 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
Min RK
f5dc005a70 Bump to 2.0.2 2022-01-10 13:54:24 +01:00
Min RK
5fd8f0f596 Merge pull request #3759 from minrk/cl-202
changelog for 2.0.2
2022-01-10 13:53:34 +01:00
Min RK
26ceafa8a3 changelog for 2.0.2 2022-01-10 13:30:14 +01:00
Min RK
2e2ed8a4ff Merge pull request #3760 from minrk/admin-th-macro
remove unused macro with missing references
2022-01-10 13:28:10 +01:00
Min RK
6cc734f884 Merge pull request #3750 from consideRatio/pr/ci-refactor-docs-workflows
ci: refactor to avoid triggering all tests on changes to docs
2022-01-10 13:27:57 +01:00
Erik Sundell
4f7f07d3b7 Fix missing docs requirements 2022-01-10 11:18:22 +01:00
Min RK
d436c97e3d remove unused macro with missing references
The th macro is unused and doesn't work
because it references `sort` template variable,
which has been removed
2022-01-10 11:09:34 +01:00
Erik Sundell
807c5b8ff9 Make the generate-scope-table script autoformat its output 2022-01-10 10:48:01 +01:00
Erik Sundell
8da06d1259 Fix git CLI flag ordering 2022-01-10 10:33:23 +01:00
Erik Sundell
1c1be8a24b Generate yaml formatted to match prettier better 2022-01-10 10:31:30 +01:00
Min RK
897606b00c Merge pull request #3754 from jupyterhub/doc-theme-config
DOCS: Update theme configuration
2022-01-10 09:34:51 +01:00
Simon Li
615af5eb33 Merge pull request #3757 from minrk/get-browser-proto
use outermost proxied entry when looking up browser protocol
2022-01-09 22:44:07 +00:00
Erik Sundell
85f94c12fc Merge pull request #3748 from jupyterhub/DOC-allowed-users
DOC: Add note about allowed_users not being set
2022-01-08 18:59:24 +01:00
Min RK
ccfee4d235 use outermost proxied entry when checking for browser protocol
wee care about what the browser sees, so trust the outermost entry instead of the innermost

This is not secure _in general_, in that these values can be spoofed by malicious proxies,
but for CORS and cookie purposes, we only care about what the browser sees,
however many hops there may be.

A malicious proxy in the chain here isn't a concern because what matters is the immediate
hop from the _browser_, not the immediate hop from the _server_.
2022-01-07 14:03:11 +01:00
Min RK
a2ba55756d Merge pull request #3746 from manics/more-cors-tests
Extra test_cors_check tests
2022-01-07 12:37:37 +01:00
pre-commit-ci[bot]
1b3e94db6c [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-04 22:23:45 +00:00
Chris Holdgraf
614d9d89d0 DOCS: Update theme configuration 2022-01-04 14:22:45 -08:00
Chris Holdgraf
05a3f5aa9a Update docs/source/getting-started/authenticators-users-basics.md
Co-authored-by: Erik Sundell <erik.i.sundell@gmail.com>
2022-01-04 13:32:39 -08:00
Erik Sundell
4f47153123 ci: cleanup comments for readability 2022-01-04 00:53:33 +01:00
Erik Sundell
a14d9ecaa1 ci: refactor to avoid triggering all tests on changes to docs 2022-01-04 00:53:33 +01:00
Erik Sundell
6815f30d36 Merge pull request #3749 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-01-03 22:33:13 +01:00
pre-commit-ci[bot]
13172e6856 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-01-03 21:06:46 +00:00
pre-commit-ci[bot]
ebc9fd7758 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.29.1 → v2.31.0](https://github.com/asottile/pyupgrade/compare/v2.29.1...v2.31.0)
2022-01-03 21:06:11 +00:00
Chris Holdgraf
0761a5db02 DOC: Add note about allowed_users not being set 2022-01-03 10:27:10 -08:00
Erik Sundell
46e7a231fe Merge pull request #3747 from minrk/https-typo
localhost URL is http, not https
2022-01-03 15:54:14 +01:00
Min RK
ffa5a20e2f localhost URL is not https 2022-01-03 15:41:54 +01:00
Simon Li
2088a57ffe Extra test_cors_check tests 2022-01-03 13:55:04 +00:00
Erik Sundell
345805781f Merge pull request #3740 from jupyterhub/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2021-12-27 22:53:25 +01:00
pre-commit-ci[bot]
9eb52ea788 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0)
2021-12-27 21:10:45 +00:00
Min RK
fb1405ecd8 Bump to 2.1.0.dev 2021-12-22 14:16:34 +01:00
34 changed files with 691 additions and 227 deletions

View File

@@ -1,15 +1,32 @@
# Build releases and (on tags) publish to PyPI
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
# Test build release artifacts (PyPI package, Docker images) and publish them on
# pushed git tags.
#
name: Release
# always build releases (to make sure wheel-building works)
# but only publish to PyPI on tags
on:
push:
branches:
- "!dependabot/**"
tags:
- "*"
pull_request:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/release.yml"
push:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/release.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
jobs:
build-release:
@@ -96,7 +113,6 @@ jobs:
# Setup docker to build for multiple platforms, see:
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
- name: Set up QEMU (for docker buildx)
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
@@ -120,6 +136,8 @@ jobs:
run: |
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
# image: jupyterhub/jupyterhub
#
# https://github.com/jupyterhub/action-major-minor-tag-calculator
# If this is a tagged build this will return additional parent tags.
# E.g. 1.2.3 is expanded to Docker tags
@@ -137,7 +155,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -146,8 +164,8 @@ jobs:
# array into a comma separated list of tags
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
# jupyterhub-onbuild
# image: jupyterhub/jupyterhub-onbuild
#
- name: Get list of jupyterhub-onbuild tags
id: onbuildtags
uses: jupyterhub/action-major-minor-tag-calculator@v2
@@ -158,7 +176,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-onbuild
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
@@ -167,8 +185,8 @@ jobs:
push: true
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
# jupyterhub-demo
# image: jupyterhub/jupyterhub-demo
#
- name: Get list of jupyterhub-demo tags
id: demotags
uses: jupyterhub/action-major-minor-tag-calculator@v2
@@ -179,7 +197,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub-demo
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with:
build-args: |
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
@@ -191,7 +209,8 @@ jobs:
push: true
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
# jupyterhub/singleuser
# image: jupyterhub/singleuser
#
- name: Get list of jupyterhub/singleuser tags
id: singleusertags
uses: jupyterhub/action-major-minor-tag-calculator@v2
@@ -202,7 +221,7 @@ jobs:
branchRegex: ^\w[\w-.]*$
- name: Build and push jupyterhub/singleuser
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f
with:
build-args: |
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}

64
.github/workflows/test-docs.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
# This workflow validates the REST API definition and runs the pytest tests in
# the docs/ folder. This workflow does not build the documentation. That is
# instead tested via ReadTheDocs (https://readthedocs.org/projects/jupyterhub/).
#
name: Test docs
# The tests defined in docs/ are currently influenced by changes to _version.py
# and scopes.py.
on:
pull_request:
paths:
- "docs/**"
- "jupyterhub/_version.py"
- "jupyterhub/scopes.py"
- ".github/workflows/*"
- "!.github/workflows/test-docs.yml"
push:
paths:
- "docs/**"
- "jupyterhub/_version.py"
- "jupyterhub/scopes.py"
- ".github/workflows/*"
- "!.github/workflows/test-docs.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
env:
# UTF-8 content may be interpreted as ascii and causes errors without this.
LANG: C.UTF-8
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs:
validate-rest-api-definition:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Validate REST API definition
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
with:
definition-file: docs/source/_static/rest-api.yml
test-docs:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: "3.9"
- name: Install requirements
run: |
pip install -r docs/requirements.txt pytest -e .
- name: pytest docs/
run: |
pytest docs/

View File

@@ -1,14 +1,28 @@
# This is a GitHub workflow defining a set of jobs with a set of steps.
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
#
name: Test
# Trigger the workflow's on all PRs but only on pushed tags or commits to
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
# to trigger.
on:
pull_request:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
push:
paths-ignore:
- "docs/**"
- "**.md"
- "**.rst"
- ".github/workflows/*"
- "!.github/workflows/test.yml"
branches-ignore:
- "dependabot/**"
- "pre-commit-ci-update-config"
tags:
- "**"
workflow_dispatch:
env:
@@ -17,25 +31,6 @@ env:
PYTEST_ADDOPTS: "--verbose --color=yes"
jobs:
rest-api:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Validate REST API
uses: char0n/swagger-editor-validate@182d1a5d26ff5c2f4f452c43bd55e2c7d8064003
with:
definition-file: docs/source/_static/rest-api.yml
- uses: actions/setup-python@v2
with:
python-version: "3.9"
# in addition to the doc requirements
# the docs *tests* require pre-commit and pytest
- run: |
pip install -r docs/requirements.txt pytest pre-commit -e .
- run: |
pytest docs/
jstest:
# Run javascript tests
runs-on: ubuntu-20.04

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v2.29.1
rev: v2.31.0
hooks:
- id: pyupgrade
args:
@@ -22,7 +22,7 @@ repos:
hooks:
- id: flake8
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v4.1.0
hooks:
- id: end-of-file-fixer
- id: check-case-conflict

View File

@@ -117,8 +117,7 @@ To start the Hub server, run the command:
jupyterhub
Visit `https://localhost:8000` in your browser, and sign in with your unix
PAM credentials.
Visit `http://localhost:8000` in your browser, and sign in with your system username and password.
_Note_: To allow multiple users to sign in to the server, you will need to
run the `jupyterhub` command as a _privileged user_, such as root.

View File

@@ -3,8 +3,10 @@
alabaster_jupyterhub
autodoc-traits
myst-parser
pre-commit
pydata-sphinx-theme
pytablewriter>=0.56
ruamel.yaml
sphinx>=1.7
sphinx-copybutton
sphinx-jsonschema

View File

@@ -6,7 +6,7 @@ info:
description: The REST API for JupyterHub
license:
name: BSD-3-Clause
version: 2.0.1
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,8 +6,83 @@ 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
2.0.2 fixes a regression in 2.0.1 causing false positives
rejecting valid requests as cross-origin,
mostly when JupyterHub is behind additional proxies.
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.1...2.0.2))
#### Bugs fixed
- use outermost proxied entry when looking up browser protocol [#3757](https://github.com/jupyterhub/jupyterhub/pull/3757) ([@minrk](https://github.com/minrk))
#### Maintenance and upkeep improvements
- remove unused macro with missing references [#3760](https://github.com/jupyterhub/jupyterhub/pull/3760) ([@minrk](https://github.com/minrk))
- ci: refactor to avoid triggering all tests on changes to docs [#3750](https://github.com/jupyterhub/jupyterhub/pull/3750) ([@consideRatio](https://github.com/consideRatio))
- Extra test_cors_check tests [#3746](https://github.com/jupyterhub/jupyterhub/pull/3746) ([@manics](https://github.com/manics))
#### Documentation improvements
- DOCS: Update theme configuration [#3754](https://github.com/jupyterhub/jupyterhub/pull/3754) ([@choldgraf](https://github.com/choldgraf))
- DOC: Add note about allowed_users not being set [#3748](https://github.com/jupyterhub/jupyterhub/pull/3748) ([@choldgraf](https://github.com/choldgraf))
- localhost URL is http, not https [#3747](https://github.com/jupyterhub/jupyterhub/pull/3747) ([@minrk](https://github.com/minrk))
#### Contributors to this release
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2021-12-22&to=2022-01-10&type=c))
[@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acholdgraf+updated%3A2021-12-22..2022-01-10&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2021-12-22..2022-01-10&type=Issues) | [@github-actions](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agithub-actions+updated%3A2021-12-22..2022-01-10&type=Issues) | [@jakob-keller](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajakob-keller+updated%3A2021-12-22..2022-01-10&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2021-12-22..2022-01-10&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ameeseeksmachine+updated%3A2021-12-22..2022-01-10&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2021-12-22..2022-01-10&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Apre-commit-ci+updated%3A2021-12-22..2022-01-10&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awelcome+updated%3A2021-12-22..2022-01-10&type=Issues)
### [2.0.1]
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.0.0...2.0.1))
@@ -1359,7 +1434,9 @@ 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.1...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
[1.5.0]: https://github.com/jupyterhub/jupyterhub/compare/1.4.2...1.5.0

View File

@@ -130,6 +130,30 @@ html_static_path = ['_static']
htmlhelp_basename = 'JupyterHubdoc'
html_theme_options = {
"icon_links": [
{
"name": "GitHub",
"url": "https://github.com/jupyterhub/jupyterhub",
"icon": "fab fa-github-square",
},
{
"name": "Discourse",
"url": "https://discourse.jupyter.org/c/jupyterhub/10",
"icon": "fab fa-discourse",
},
],
"use_edit_page_button": True,
"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

@@ -16,6 +16,10 @@ c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
Users in the `allowed_users` set are added to the Hub database when the Hub is
started.
```{warning}
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
```
## Configure admins (`admin_users`)
```{note}

View File

@@ -1,16 +1,33 @@
"""
This script updates two files with the RBAC scope descriptions found in
`scopes.py`.
The files are:
1. scope-table.md
This file is git ignored and referenced by the documentation.
2. rest-api.yml
This file is JupyterHub's REST API schema. Both a version and the RBAC
scopes descriptions are updated in it.
"""
import os
from collections import defaultdict
from pathlib import Path
from subprocess import run
from pytablewriter import MarkdownTableWriter
from ruamel.yaml import YAML
import jupyterhub
from jupyterhub import __version__
from jupyterhub.scopes import scope_definitions
HERE = os.path.abspath(os.path.dirname(__file__))
DOCS = Path(HERE).parent.parent.absolute()
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
SCOPE_TABLE_MD = Path(HERE).joinpath("scope-table.md")
class ScopeTableGenerator:
@@ -82,8 +99,9 @@ class ScopeTableGenerator:
return table_rows
def write_table(self):
"""Generates the scope table in markdown format and writes it into `scope-table.md`"""
filename = f"{HERE}/scope-table.md"
"""Generates the RBAC scopes reference documentation as a markdown table
and writes it to the .gitignored `scope-table.md`."""
filename = SCOPE_TABLE_MD
table_name = ""
headers = ["Scope", "Grants permission to:"]
values = self._parse_scopes()
@@ -99,15 +117,20 @@ class ScopeTableGenerator:
)
def write_api(self):
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
"""Loads `rest-api.yml` and writes it back with a dynamically set
JupyterHub version field and list of RBAC scopes descriptions from
`scopes.py`."""
filename = REST_API_YAML
yaml = YAML(typ='rt')
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
yaml.indent(mapping=2, offset=2, sequence=4)
scope_dict = {}
with open(filename) as f:
content = yaml.load(f.read())
content["info"]["version"] = jupyterhub.__version__
content["info"]["version"] = __version__
for scope in self.scopes:
description = self.scopes[scope]['description']
doc_description = self.scopes[scope].get('doc_description', '')
@@ -121,6 +144,12 @@ class ScopeTableGenerator:
with open(filename, 'w') as f:
yaml.dump(content, f)
run(
['pre-commit', 'run', 'prettier', '--files', filename],
cwd=HERE,
check=False,
)
def main():
table_generator = ScopeTableGenerator()

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

@@ -10,7 +10,9 @@ here = Path(__file__).absolute().parent
root = here.parent
def test_rest_api_version():
def test_rest_api_version_is_updated():
"""Checks that the version in JupyterHub's REST API definition file
(rest-api.yml) is matching the JupyterHub version."""
version_py = root.joinpath("jupyterhub", "_version.py")
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
ns = {}
@@ -25,18 +27,17 @@ def test_rest_api_version():
assert jupyterhub_version == rest_api_version
def test_restapi_scopes():
def test_rest_api_rbac_scope_descriptions_are_updated():
"""Checks that the RBAC scope descriptions in JupyterHub's REST API
definition file (rest-api.yml) as can be updated by generate-scope-table.py
matches what is committed."""
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
run(
['pre-commit', 'run', 'prettier', '--files', 'source/_static/rest-api.yml'],
cwd=here,
check=False,
)
run(
[
"git",
"diff",
"--no-pager",
"diff",
"--color=always",
"--exit-code",
str(here.joinpath("source", "_static", "rest-api.yml")),
],

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, 1, "", "")
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

@@ -16,6 +16,7 @@ from tornado import web
from .. import orm
from .. import roles
from .. import scopes
from ..utils import get_browser_protocol
from ..utils import token_authenticated
from .base import APIHandler
from .base import BaseHandler
@@ -115,7 +116,10 @@ class OAuthHandler:
# make absolute local redirects full URLs
# to satisfy oauthlib's absolute URI requirement
redirect_uri = (
self.request.protocol + "://" + self.request.headers['Host'] + redirect_uri
get_browser_protocol(self.request)
+ "://"
+ self.request.host
+ redirect_uri
)
parsed_url = urlparse(uri)
query_list = parse_qsl(parsed_url.query, keep_blank_values=True)

View File

@@ -14,6 +14,7 @@ from tornado import web
from .. import orm
from ..handlers import BaseHandler
from ..utils import get_browser_protocol
from ..utils import isoformat
from ..utils import url_path_join
@@ -60,6 +61,8 @@ class APIHandler(BaseHandler):
"""
host_header = self.app.forwarded_host_header or "Host"
host = self.request.headers.get(host_header)
if host and "," in host:
host = host.split(",", 1)[0].strip()
referer = self.request.headers.get("Referer")
# If no header is provided, assume it comes from a script/curl.
@@ -71,7 +74,8 @@ class APIHandler(BaseHandler):
self.log.warning("Blocking API request with no referer")
return False
proto = self.request.protocol
proto = get_browser_protocol(self.request)
full_host = f"{proto}://{host}{self.hub.base_url}"
host_url = urlparse(full_host)
referer_url = urlparse(referer)

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,10 +45,12 @@ 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
from ..utils import get_accepted_mimetype
from ..utils import get_browser_protocol
from ..utils import maybe_future
from ..utils import url_path_join
@@ -632,12 +634,10 @@ class BaseHandler(RequestHandler):
next_url = self.get_argument('next', default='')
# protect against some browsers' buggy handling of backslash as slash
next_url = next_url.replace('\\', '%5C')
if (next_url + '/').startswith(
(
f'{self.request.protocol}://{self.request.host}/',
f'//{self.request.host}/',
)
) or (
proto = get_browser_protocol(self.request)
host = self.request.host
if (next_url + '/').startswith((f'{proto}://{host}/', f'//{host}/',)) or (
self.subdomain_host
and urlparse(next_url).netloc
and ("." + urlparse(next_url).netloc).endswith(
@@ -1449,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

@@ -53,6 +53,7 @@ from traitlets import validate
from traitlets.config import SingletonConfigurable
from ..scopes import _intersect_expanded_scopes
from ..utils import get_browser_protocol
from ..utils import url_path_join
@@ -772,7 +773,7 @@ class HubOAuth(HubAuth):
# OAuth that doesn't complete shouldn't linger too long.
'max_age': 600,
}
if handler.request.protocol == 'https':
if get_browser_protocol(handler.request) == 'https':
kwargs['secure'] = True
# load user cookie overrides
kwargs.update(self.cookie_options)
@@ -812,7 +813,7 @@ class HubOAuth(HubAuth):
def set_cookie(self, handler, access_token):
"""Set a cookie recording OAuth result"""
kwargs = {'path': self.base_url, 'httponly': True}
if handler.request.protocol == 'https':
if get_browser_protocol(handler.request) == 'https':
kwargs['secure'] = True
# load user cookie overrides
kwargs.update(self.cookie_options)

View File

@@ -98,27 +98,62 @@ async def test_post_content_type(app, content_type, status):
@mark.parametrize(
"host, referer, status",
"host, referer, extraheaders, status",
[
('$host', '$url', 200),
(None, None, 200),
(None, 'null', 403),
(None, 'http://attack.com/csrf/vulnerability', 403),
('$host', {"path": "/user/someuser"}, 403),
('$host', {"path": "{path}/foo/bar/subpath"}, 200),
('$host', '$url', {}, 200),
(None, None, {}, 200),
(None, 'null', {}, 403),
(None, 'http://attack.com/csrf/vulnerability', {}, 403),
('$host', {"path": "/user/someuser"}, {}, 403),
('$host', {"path": "{path}/foo/bar/subpath"}, {}, 200),
# mismatch host
("mismatch.com", "$url", 403),
("mismatch.com", "$url", {}, 403),
# explicit host, matches
("fake.example", {"netloc": "fake.example"}, 200),
("fake.example", {"netloc": "fake.example"}, {}, 200),
# explicit port, matches implicit port
("fake.example:80", {"netloc": "fake.example"}, 200),
("fake.example:80", {"netloc": "fake.example"}, {}, 200),
# explicit port, mismatch
("fake.example:81", {"netloc": "fake.example"}, 403),
("fake.example:81", {"netloc": "fake.example"}, {}, 403),
# implicit ports, mismatch proto
("fake.example", {"netloc": "fake.example", "scheme": "https"}, 403),
("fake.example", {"netloc": "fake.example", "scheme": "https"}, {}, 403),
# explicit ports, match
("fake.example:81", {"netloc": "fake.example:81"}, {}, 200),
# Test proxy protocol defined headers taken into account by utils.get_browser_protocol
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{'X-Scheme': 'https'},
200,
),
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{'X-Forwarded-Proto': 'https'},
200,
),
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{
'Forwarded': 'host=fake.example;proto=https,for=1.2.34;proto=http',
'X-Scheme': 'http',
},
200,
),
(
"fake.example",
{"netloc": "fake.example", "scheme": "https"},
{
'Forwarded': 'host=fake.example;proto=http,for=1.2.34;proto=http',
'X-Scheme': 'https',
},
403,
),
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https'}, 403),
("fake.example", {"netloc": "fake.example"}, {'X-Scheme': 'https, http'}, 403),
],
)
async def test_cors_check(request, app, host, referer, status):
async def test_cors_check(request, app, host, referer, extraheaders, status):
url = ujoin(public_host(app), app.hub.base_url)
real_host = urlparse(url).netloc
if host == "$host":
@@ -140,6 +175,7 @@ async def test_cors_check(request, app, host, referer, status):
headers['X-Forwarded-Host'] = host
if referer is not None:
headers['Referer'] = referer
headers.update(extraheaders)
# add admin user
user = find_user(app.db, 'admin')

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

@@ -2,12 +2,16 @@
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import Mock
import pytest
from async_generator import aclosing
from tornado import gen
from tornado.concurrent import run_on_executor
from tornado.httpserver import HTTPRequest
from tornado.httputil import HTTPHeaders
from .. import utils
from ..utils import iterate_until
@@ -88,3 +92,33 @@ async def test_tornado_coroutines():
# verify that tornado gen and executor methods return awaitables
assert (await t.on_executor()) == "executor"
assert (await t.tornado_coroutine()) == "gen.coroutine"
@pytest.mark.parametrize(
"forwarded, x_scheme, x_forwarded_proto, expected",
[
("", "", "", "_attr_"),
("for=1.2.3.4", "", "", "_attr_"),
("for=1.2.3.4,proto=https", "", "", "_attr_"),
("", "https", "http", "https"),
("", "https, http", "", "https"),
("", "https, http", "http", "https"),
("proto=http ; for=1.2.3.4, proto=https", "https, http", "", "http"),
("proto=invalid;for=1.2.3.4,proto=http", "https, http", "", "https"),
("for=1.2.3.4,proto=http", "https, http", "", "https"),
("", "invalid, http", "", "_attr_"),
],
)
def test_browser_protocol(x_scheme, x_forwarded_proto, forwarded, expected):
request = Mock(spec=HTTPRequest)
request.protocol = "_attr_"
request.headers = HTTPHeaders()
if x_scheme:
request.headers["X-Scheme"] = x_scheme
if x_forwarded_proto:
request.headers["X-Forwarded-Proto"] = x_forwarded_proto
if forwarded:
request.headers["Forwarded"] = forwarded
proto = utils.get_browser_protocol(request)
assert proto == expected

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
@@ -355,7 +357,7 @@ def hash_token(token, salt=8, rounds=16384, algorithm='sha512'):
h.update(btoken)
digest = h.hexdigest()
return "{algorithm}:{rounds}:{salt}:{digest}".format(**locals())
return f"{algorithm}:{rounds}:{salt}:{digest}"
def compare_token(compare, token):
@@ -683,3 +685,44 @@ def catch_db_error(f):
return r
return catching
def get_browser_protocol(request):
"""Get the _protocol_ seen by the browser
Like tornado's _apply_xheaders,
but in the case of multiple proxy hops,
use the outermost value (what the browser likely sees)
instead of the innermost value,
which is the most trustworthy.
We care about what the browser sees,
not where the request actually came from,
so trusting possible spoofs is the right thing to do.
"""
headers = request.headers
# first choice: Forwarded header
forwarded_header = headers.get("Forwarded")
if forwarded_header:
first_forwarded = forwarded_header.split(",", 1)[0].strip()
fields = {}
forwarded_dict = {}
for field in first_forwarded.split(";"):
key, _, value = field.partition("=")
fields[key.strip().lower()] = value.strip()
if "proto" in fields and fields["proto"].lower() in {"http", "https"}:
return fields["proto"].lower()
else:
app_log.warning(
f"Forwarded header present without protocol: {forwarded_header}"
)
# second choice: X-Scheme or X-Forwarded-Proto
proto_header = headers.get("X-Scheme", headers.get("X-Forwarded-Proto", None))
if proto_header:
proto_header = proto_header.split(",")[0].strip().lower()
if proto_header in {"http", "https"}:
return proto_header
# no forwarded headers
return request.protocol

View File

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

View File

@@ -1,20 +1,5 @@
{% extends "page.html" %}
{% macro th(label, key='', colspan=1) %}
<th data-sort="{{key}}" colspan="{{colspan}}">{{label}}
{% if key %}
<a href="#"><i class="fa {% if sort.get(key) == 'asc' -%}
fa-sort-asc
{%- elif sort.get(key) == 'desc' -%}
fa-sort-desc
{%- else -%}
fa-sort
{%- endif %} sort-icon">
</i></a>
{% endif %}
</th>
{% endmacro %}
{% block main %}
<div id="react-admin-hook">
<script id="jupyterhub-admin-config">

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