mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-16 22:43:00 +00:00
Merge branch 'jupyterhub:main' into group_property_feature
This commit is contained in:
108
.github/workflows/test-jsx.yml
vendored
Normal file
108
.github/workflows/test-jsx.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# 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
|
||||||
|
#
|
||||||
|
name: Test jsx (admin-react.js)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "jsx/**"
|
||||||
|
- ".github/workflows/test-jsx.yml"
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "jsx/**"
|
||||||
|
- ".github/workflows/test-jsx.yml"
|
||||||
|
branches-ignore:
|
||||||
|
- "dependabot/**"
|
||||||
|
- "pre-commit-ci-update-config"
|
||||||
|
tags:
|
||||||
|
- "**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The ./jsx folder contains React based source code files that are to compile
|
||||||
|
# to share/jupyterhub/static/js/admin-react.js. The ./jsx folder includes
|
||||||
|
# tests also has tests that this job is meant to run with `yarn test`
|
||||||
|
# according to the documentation in jsx/README.md.
|
||||||
|
test-jsx-admin-react:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: "14"
|
||||||
|
|
||||||
|
- name: Install yarn
|
||||||
|
run: |
|
||||||
|
npm install -g yarn
|
||||||
|
|
||||||
|
- name: yarn
|
||||||
|
run: |
|
||||||
|
cd jsx
|
||||||
|
yarn
|
||||||
|
|
||||||
|
- name: yarn test
|
||||||
|
run: |
|
||||||
|
cd jsx
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# The ./jsx folder contains React based source files that are to compile to
|
||||||
|
# share/jupyterhub/static/js/admin-react.js. This job makes sure that whatever
|
||||||
|
# we have in jsx/src matches the compiled asset that we package and
|
||||||
|
# distribute.
|
||||||
|
#
|
||||||
|
# This job's purpose is to make sure we don't forget to compile changes and to
|
||||||
|
# verify nobody sneaks in a change in the hard to review compiled asset.
|
||||||
|
#
|
||||||
|
# NOTE: In the future we may want to stop version controlling the compiled
|
||||||
|
# artifact and instead generate it whenever we package JupyterHub. If we
|
||||||
|
# do this, we are required to setup node and compile the source code
|
||||||
|
# more often, at the same time we could avoid having this check be made.
|
||||||
|
#
|
||||||
|
compile-jsx-admin-react:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: "14"
|
||||||
|
|
||||||
|
- name: Install yarn
|
||||||
|
run: |
|
||||||
|
npm install -g yarn
|
||||||
|
|
||||||
|
- name: yarn
|
||||||
|
run: |
|
||||||
|
cd jsx
|
||||||
|
yarn
|
||||||
|
|
||||||
|
- name: yarn build
|
||||||
|
run: |
|
||||||
|
cd jsx
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
- name: yarn place
|
||||||
|
run: |
|
||||||
|
cd jsx
|
||||||
|
yarn place
|
||||||
|
|
||||||
|
- name: Verify compiled jsx/src matches version controlled artifact
|
||||||
|
run: |
|
||||||
|
if [[ `git status --porcelain=v1` ]]; then
|
||||||
|
echo "The source code in ./jsx compiles to something different than found in ./share/jupyterhub/static/js/admin-react.js!"
|
||||||
|
echo
|
||||||
|
echo "Please re-compile the source code in ./jsx with the following commands:"
|
||||||
|
echo
|
||||||
|
echo "yarn"
|
||||||
|
echo "yarn build"
|
||||||
|
echo "yarn place"
|
||||||
|
echo
|
||||||
|
echo "See ./jsx/README.md for more details."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "Compilation of jsx/src to share/jupyterhub/static/js/admin-react.js didn't lead to changes."
|
||||||
|
fi
|
27
.github/workflows/test.yml
vendored
27
.github/workflows/test.yml
vendored
@@ -31,33 +31,6 @@ env:
|
|||||||
PYTEST_ADDOPTS: "--verbose --color=yes"
|
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
jstest:
|
|
||||||
# Run javascript tests
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
|
|
||||||
# environment and setup in a fraction of a second.
|
|
||||||
- name: Install Node
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: "14"
|
|
||||||
|
|
||||||
- name: Install Node dependencies
|
|
||||||
run: |
|
|
||||||
npm install -g yarn
|
|
||||||
|
|
||||||
- name: Run yarn
|
|
||||||
run: |
|
|
||||||
cd jsx
|
|
||||||
yarn
|
|
||||||
|
|
||||||
- name: yarn test
|
|
||||||
run: |
|
|
||||||
cd jsx
|
|
||||||
yarn test
|
|
||||||
|
|
||||||
# Run "pytest jupyterhub/tests" in various configurations
|
# Run "pytest jupyterhub/tests" in various configurations
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
|
@@ -1,30 +1,53 @@
|
|||||||
|
# pre-commit is a tool to perform a predefined set of tasks manually and/or
|
||||||
|
# automatically before git commits are made.
|
||||||
|
#
|
||||||
|
# Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level
|
||||||
|
#
|
||||||
|
# Common tasks
|
||||||
|
#
|
||||||
|
# - Run on all files: pre-commit run --all-files
|
||||||
|
# - Register git hooks: pre-commit install --install-hooks
|
||||||
|
#
|
||||||
repos:
|
repos:
|
||||||
|
# Autoformat: Python code, syntax patterns are modernized
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v2.31.0
|
rev: v2.31.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args:
|
args:
|
||||||
- --py36-plus
|
- --py36-plus
|
||||||
|
|
||||||
|
# Autoformat: Python code
|
||||||
- repo: https://github.com/asottile/reorder_python_imports
|
- repo: https://github.com/asottile/reorder_python_imports
|
||||||
rev: v2.7.1
|
rev: v3.0.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: reorder-python-imports
|
- id: reorder-python-imports
|
||||||
|
|
||||||
|
# Autoformat: Python code
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 22.1.0
|
rev: 22.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
|
args: [--target-version=py36]
|
||||||
|
|
||||||
|
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v2.5.1
|
rev: v2.5.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
- repo: https://github.com/PyCQA/flake8
|
|
||||||
rev: "4.0.1"
|
# Autoformat and linting, misc. details
|
||||||
hooks:
|
|
||||||
- id: flake8
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.1.0
|
rev: v4.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
exclude: share/jupyterhub/static/js/admin-react.js
|
||||||
|
- id: requirements-txt-fixer
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-executables-have-shebangs
|
- id: check-executables-have-shebangs
|
||||||
- id: requirements-txt-fixer
|
|
||||||
|
# Linting: Python code (see the file .flake8)
|
||||||
|
- repo: https://github.com/PyCQA/flake8
|
||||||
|
rev: "4.0.1"
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
@@ -6,7 +6,7 @@ info:
|
|||||||
description: The REST API for JupyterHub
|
description: The REST API for JupyterHub
|
||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
version: 2.2.0.dev
|
version: 2.3.0.dev
|
||||||
servers:
|
servers:
|
||||||
- url: /hub/api
|
- url: /hub/api
|
||||||
security:
|
security:
|
||||||
|
37
docs/source/admin/log-messages.md
Normal file
37
docs/source/admin/log-messages.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Common log messages emitted by JupyterHub
|
||||||
|
|
||||||
|
When debugging errors and outages, looking at the logs emitted by
|
||||||
|
JupyterHub is very helpful. This document tries to document some common
|
||||||
|
log messages, and what they mean.
|
||||||
|
|
||||||
|
## Failing suspected API request to not-running server
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
Your logs might be littered with lines that might look slightly scary
|
||||||
|
|
||||||
|
```
|
||||||
|
[W 2022-03-10 17:25:19.774 JupyterHub base:1349] Failing suspected API request to not-running server: /hub/user/<user-name>/api/metrics/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Most likely cause
|
||||||
|
|
||||||
|
This likely means is that the user's server has stopped running but they
|
||||||
|
still have a browser tab open. For example, you might have 3 tabs open, and shut
|
||||||
|
your server down via one. Or you closed your laptop, your server was
|
||||||
|
culled for inactivity, and then you reopen your laptop again! The
|
||||||
|
client side code (JupyterLab, Classic Notebook, etc) does not know
|
||||||
|
yet that the server is dead, and continues to make some API requests.
|
||||||
|
JupyterHub's architecture means that the proxy routes all requests that
|
||||||
|
don't go to a running user server to the hub process itself. The hub
|
||||||
|
process then explicitly returns a failure response, so the client knows
|
||||||
|
that the server is not running anymore. This is used by JupyterLab to
|
||||||
|
tell you your server is not running anymore, and offer you the option
|
||||||
|
to let you restart it.
|
||||||
|
|
||||||
|
Most commonly, you'll see this in reference to the `/api/metrics/v1`
|
||||||
|
URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage).
|
||||||
|
|
||||||
|
### Actions you can take
|
||||||
|
|
||||||
|
This log message is benign, and there is usually no action for you to take.
|
@@ -6,9 +6,102 @@ command line for details.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## 2.2
|
||||||
|
|
||||||
|
### 2.2.2 2022-03-14
|
||||||
|
|
||||||
|
2.2.2 fixes a small regressions in 2.2.1.
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.2.1...6c5e5452bc734dfd5c5a9482e4980b988ddd304e))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- Fix failure to update admin-react.js by re-compiling from our source [#3825](https://github.com/jupyterhub/jupyterhub/pull/3825) ([@NarekA](https://github.com/NarekA), [@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
|
||||||
|
#### Continuous integration improvements
|
||||||
|
|
||||||
|
- ci: standalone jsx workflow and verify compiled asset matches source code [#3826](https://github.com/jupyterhub/jupyterhub/pull/3826) ([@consideRatio](https://github.com/consideRatio), [@NarekA](https://github.com/NarekA))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-03-11&to=2022-03-14&type=c))
|
||||||
|
|
||||||
|
[@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-03-11..2022-03-14&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2022-03-11..2022-03-14&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-03-11..2022-03-14&type=Issues) | [@NarekA](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ANarekA+updated%3A2022-03-11..2022-03-14&type=Issues)
|
||||||
|
|
||||||
|
### 2.2.1 2022-03-11
|
||||||
|
|
||||||
|
2.2.1 fixes a few small regressions in 2.2.0.
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.2.0...2.2.1))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- Fix clearing cookie with custom xsrf cookie options [#3823](https://github.com/jupyterhub/jupyterhub/pull/3823) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Fix admin dashboard table sorting [#3822](https://github.com/jupyterhub/jupyterhub/pull/3822) ([@NarekA](https://github.com/NarekA), [@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Maintenance and upkeep improvements
|
||||||
|
|
||||||
|
- allow Spawner.server to be mocked without underlying orm_spawner [#3819](https://github.com/jupyterhub/jupyterhub/pull/3819) ([@minrk](https://github.com/minrk), [@yuvipanda](https://github.com/yuvipanda), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
|
||||||
|
- Add some docs on common log messages [#3820](https://github.com/jupyterhub/jupyterhub/pull/3820) ([@yuvipanda](https://github.com/yuvipanda), [@choldgraf](https://github.com/choldgraf), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2022-03-07&to=2022-03-11&type=c))
|
||||||
|
|
||||||
|
[@choldgraf](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acholdgraf+updated%3A2022-03-07..2022-03-11&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-03-07..2022-03-11&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-03-07..2022-03-11&type=Issues) | [@NarekA](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ANarekA+updated%3A2022-03-07..2022-03-11&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2022-03-07..2022-03-11&type=Issues)
|
||||||
|
|
||||||
|
# 2.2.0 2022-03-07
|
||||||
|
|
||||||
|
JupyterHub 2.2.0 is a small release.
|
||||||
|
The main new feature is the ability of Authenticators to [manage group membership](authenticator-groups),
|
||||||
|
e.g. when the identity provider has its own concept of groups that should be preserved
|
||||||
|
in JupyterHub.
|
||||||
|
|
||||||
|
The links to access user servers from the admin page have been restored.
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/2.1.1...2.2.0))
|
||||||
|
|
||||||
|
#### New features added
|
||||||
|
|
||||||
|
- Enable `options_from_form(spawner, form_data)` signature from configuration file [#3791](https://github.com/jupyterhub/jupyterhub/pull/3791) ([@rcthomas](https://github.com/rcthomas), [@minrk](https://github.com/minrk))
|
||||||
|
- Authenticator user group management [#3548](https://github.com/jupyterhub/jupyterhub/pull/3548) ([@thomafred](https://github.com/thomafred), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Enhancements made
|
||||||
|
|
||||||
|
- Add user token to JupyterLab PageConfig [#3809](https://github.com/jupyterhub/jupyterhub/pull/3809) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- show insecure-login-warning for all authenticators [#3793](https://github.com/jupyterhub/jupyterhub/pull/3793) ([@satra](https://github.com/satra), [@minrk](https://github.com/minrk))
|
||||||
|
- short-circuit token permission check if token and owner share role [#3792](https://github.com/jupyterhub/jupyterhub/pull/3792) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Named server support, access links in admin page [#3790](https://github.com/jupyterhub/jupyterhub/pull/3790) ([@NarekA](https://github.com/NarekA), [@minrk](https://github.com/minrk), [@ykazakov](https://github.com/ykazakov), [@manics](https://github.com/manics))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- Keep Spawner.server in sync with underlying orm_spawner.server [#3810](https://github.com/jupyterhub/jupyterhub/pull/3810) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics), [@GeorgianaElena](https://github.com/GeorgianaElena), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Replace failed spawners when starting new launch [#3802](https://github.com/jupyterhub/jupyterhub/pull/3802) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Log proxy's public_url only when started by JupyterHub [#3781](https://github.com/jupyterhub/jupyterhub/pull/3781) ([@cqzlxl](https://github.com/cqzlxl), [@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Documentation improvements
|
||||||
|
|
||||||
|
- Apache2 Documentation: Updates Reverse Proxy Configuration (TLS/SSL, Protocols, Headers) [#3813](https://github.com/jupyterhub/jupyterhub/pull/3813) ([@rzo1](https://github.com/rzo1), [@minrk](https://github.com/minrk))
|
||||||
|
- Update example to not reference an undefined scope [#3812](https://github.com/jupyterhub/jupyterhub/pull/3812) ([@ktaletsk](https://github.com/ktaletsk), [@minrk](https://github.com/minrk))
|
||||||
|
- Apache: set X-Forwarded-Proto header [#3808](https://github.com/jupyterhub/jupyterhub/pull/3808) ([@manics](https://github.com/manics), [@consideRatio](https://github.com/consideRatio), [@rzo1](https://github.com/rzo1), [@tobi45](https://github.com/tobi45))
|
||||||
|
- idle-culler example config missing closing bracket [#3803](https://github.com/jupyterhub/jupyterhub/pull/3803) ([@tmtabor](https://github.com/tmtabor), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Behavior Changes
|
||||||
|
|
||||||
|
- Stop opening PAM sessions by default [#3787](https://github.com/jupyterhub/jupyterhub/pull/3787) ([@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-25&to=2022-03-07&type=c))
|
||||||
|
|
||||||
|
[@blink1073](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ablink1073+updated%3A2022-01-25..2022-03-07&type=Issues) | [@clkao](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aclkao+updated%3A2022-01-25..2022-03-07&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2022-01-25..2022-03-07&type=Issues) | [@cqzlxl](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acqzlxl+updated%3A2022-01-25..2022-03-07&type=Issues) | [@dependabot](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adependabot+updated%3A2022-01-25..2022-03-07&type=Issues) | [@dtaniwaki](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adtaniwaki+updated%3A2022-01-25..2022-03-07&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Afcollonval+updated%3A2022-01-25..2022-03-07&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AGeorgianaElena+updated%3A2022-01-25..2022-03-07&type=Issues) | [@github-actions](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Agithub-actions+updated%3A2022-01-25..2022-03-07&type=Issues) | [@kshitija08](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akshitija08+updated%3A2022-01-25..2022-03-07&type=Issues) | [@ktaletsk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aktaletsk+updated%3A2022-01-25..2022-03-07&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2022-01-25..2022-03-07&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2022-01-25..2022-03-07&type=Issues) | [@NarekA](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ANarekA+updated%3A2022-01-25..2022-03-07&type=Issues) | [@pre-commit-ci](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Apre-commit-ci+updated%3A2022-01-25..2022-03-07&type=Issues) | [@rajat404](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arajat404+updated%3A2022-01-25..2022-03-07&type=Issues) | [@rcthomas](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arcthomas+updated%3A2022-01-25..2022-03-07&type=Issues) | [@ryogesh](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aryogesh+updated%3A2022-01-25..2022-03-07&type=Issues) | [@rzo1](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arzo1+updated%3A2022-01-25..2022-03-07&type=Issues) | [@satra](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asatra+updated%3A2022-01-25..2022-03-07&type=Issues) | [@thomafred](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Athomafred+updated%3A2022-01-25..2022-03-07&type=Issues) | [@tmtabor](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atmtabor+updated%3A2022-01-25..2022-03-07&type=Issues) | [@tobi45](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atobi45+updated%3A2022-01-25..2022-03-07&type=Issues) | [@ykazakov](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aykazakov+updated%3A2022-01-25..2022-03-07&type=Issues)
|
||||||
|
|
||||||
## 2.1
|
## 2.1
|
||||||
|
|
||||||
### 2.1.1 2021-01-25
|
### 2.1.1 2022-01-25
|
||||||
|
|
||||||
2.1.1 is a tiny bugfix release,
|
2.1.1 is a tiny bugfix release,
|
||||||
fixing an issue where admins did not receive the new `read:metrics` permission.
|
fixing an issue where admins did not receive the new `read:metrics` permission.
|
||||||
|
@@ -21,6 +21,7 @@ extensions = [
|
|||||||
'myst_parser',
|
'myst_parser',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
myst_heading_anchors = 2
|
||||||
myst_enable_extensions = [
|
myst_enable_extensions = [
|
||||||
'colon_fence',
|
'colon_fence',
|
||||||
'deflist',
|
'deflist',
|
||||||
|
@@ -10,4 +10,5 @@ well as other information relevant to running your own JupyterHub over time.
|
|||||||
|
|
||||||
troubleshooting
|
troubleshooting
|
||||||
admin/upgrading
|
admin/upgrading
|
||||||
|
admin/log-messages
|
||||||
changelog
|
changelog
|
||||||
|
@@ -119,6 +119,119 @@ Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can
|
|||||||
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
|
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Custom scopes
|
||||||
|
|
||||||
|
:::{versionadded} 2.3
|
||||||
|
:::
|
||||||
|
|
||||||
|
JupyterHub 2.3 introduces support for custom scopes.
|
||||||
|
Services that use JupyterHub for authentication and want to implement their own granular access may define additional _custom_ scopes and assign them to users with JupyterHub roles.
|
||||||
|
|
||||||
|
% Note: keep in sync with pattern/description in jupyterhub/scopes.py
|
||||||
|
|
||||||
|
Custom scope names must start with `custom:`
|
||||||
|
and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk (`-_:*`).
|
||||||
|
The part after `custom:` must start with a letter or number.
|
||||||
|
Scopes may not end with a hyphen or colon.
|
||||||
|
|
||||||
|
The only strict requirement is that a custom scope definition must have a `description`.
|
||||||
|
It _may_ also have `subscopes` if you are defining multiple scopes that have a natural hierarchy,
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.custom_scopes = {
|
||||||
|
"custom:myservice:read": {
|
||||||
|
"description": "read-only access to myservice",
|
||||||
|
},
|
||||||
|
"custom:myservice:write": {
|
||||||
|
"description": "write access to myservice",
|
||||||
|
# write permission implies read permission
|
||||||
|
"subscopes": [
|
||||||
|
"custom:myservice:read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
# graders have read-only access to the service
|
||||||
|
{
|
||||||
|
"name": "service-user",
|
||||||
|
"groups": ["graders"],
|
||||||
|
"scopes": [
|
||||||
|
"custom:myservice:read",
|
||||||
|
"access:service!service=myservice",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# instructors have read and write access to the service
|
||||||
|
{
|
||||||
|
"name": "service-admin",
|
||||||
|
"groups": ["instructors"],
|
||||||
|
"scopes": [
|
||||||
|
"custom:myservice:write",
|
||||||
|
"access:service!service=myservice",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
In the above configuration, two scopes are defined:
|
||||||
|
|
||||||
|
- `custom:myservice:read` grants read-only access to the service, and
|
||||||
|
- `custom:myservice:write` grants write access to the service
|
||||||
|
- write access _implies_ read access via the `subscope`
|
||||||
|
|
||||||
|
These custom scopes are assigned to two groups via `roles`:
|
||||||
|
|
||||||
|
- users in the group `graders` are granted read access to the service
|
||||||
|
- users in the group `instructors` are
|
||||||
|
- both are granted _access_ to the service via `access:service!service=myservice`
|
||||||
|
|
||||||
|
When the service completes OAuth, it will retrieve the user model from `/hub/api/user`.
|
||||||
|
This model includes a `scopes` field which is a list of authorized scope for the request,
|
||||||
|
which can be used.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def require_scope(scope):
|
||||||
|
"""decorator to require a scope to perform an action"""
|
||||||
|
def wrapper(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapped_func(request):
|
||||||
|
user = fetch_hub_api_user(request.token)
|
||||||
|
if scope not in user["scopes"]:
|
||||||
|
raise HTTP403(f"Requires scope {scope}")
|
||||||
|
else:
|
||||||
|
return func()
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
@require_scope("custom:myservice:read")
|
||||||
|
async def read_something(request):
|
||||||
|
...
|
||||||
|
|
||||||
|
@require_scope("custom:myservice:write")
|
||||||
|
async def write_something(request):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use {class}`~.HubOAuthenticated`, this check is performed automatically
|
||||||
|
against the `.hub_scopes` attribute of each Handler
|
||||||
|
(the default is populated from `$JUPYTERHUB_OAUTH_SCOPES` and usually `access:services!service=myservice`).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from tornado import web
|
||||||
|
from jupyterhub.services.auth import HubOAuthenticated
|
||||||
|
|
||||||
|
class MyHandler(HubOAuthenticated, BaseHandler):
|
||||||
|
hub_scopes = ["custom:myservice:read"]
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def get(self):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing scope filters (`!user=`, etc.) may be applied to custom scopes.
|
||||||
|
Custom scope _filters_ are NOT supported.
|
||||||
|
|
||||||
### Scopes and APIs
|
### Scopes and APIs
|
||||||
|
|
||||||
The scopes are also listed in the [](../reference/rest-api.rst) documentation. Each API endpoint has a list of scopes which can be used to access the API; if no scopes are listed, the API is not authenticated and can be accessed without any permissions (i.e., no scopes).
|
The scopes are also listed in the [](../reference/rest-api.rst) documentation. Each API endpoint has a list of scopes which can be used to access the API; if no scopes are listed, the API is not authenticated and can be accessed without any permissions (i.e., no scopes).
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
# Authenticators
|
# Authenticators
|
||||||
|
|
||||||
The [Authenticator][] is the mechanism for authorizing users to use the
|
The {class}`.Authenticator` is the mechanism for authorizing users to use the
|
||||||
Hub and single user notebook servers.
|
Hub and single user notebook servers.
|
||||||
|
|
||||||
## The default PAM Authenticator
|
## The default PAM Authenticator
|
||||||
@@ -137,8 +137,8 @@ via other mechanisms. One such example is using [GitHub OAuth][].
|
|||||||
|
|
||||||
Because the username is passed from the Authenticator to the Spawner,
|
Because the username is passed from the Authenticator to the Spawner,
|
||||||
a custom Authenticator and Spawner are often used together.
|
a custom Authenticator and Spawner are often used together.
|
||||||
For example, the Authenticator methods, [pre_spawn_start(user, spawner)][]
|
For example, the Authenticator methods, {meth}`.Authenticator.pre_spawn_start`
|
||||||
and [post_spawn_stop(user, spawner)][], are hooks that can be used to do
|
and {meth}`.Authenticator.post_spawn_stop`, are hooks that can be used to do
|
||||||
auth-related startup (e.g. opening PAM sessions) and cleanup
|
auth-related startup (e.g. opening PAM sessions) and cleanup
|
||||||
(e.g. closing PAM sessions).
|
(e.g. closing PAM sessions).
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ If there are multiple keys present, the **first** key is always used to persist
|
|||||||
|
|
||||||
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
|
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
|
||||||
This may mean defining environment variables, placing certificate in the user's home directory, etc.
|
This may mean defining environment variables, placing certificate in the user's home directory, etc.
|
||||||
The `Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
|
The {meth}`Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
|
||||||
to Spawner environment:
|
to Spawner environment:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -247,6 +247,8 @@ class MyAuthenticator(Authenticator):
|
|||||||
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(authenticator-groups)=
|
||||||
|
|
||||||
## Authenticator-managed group membership
|
## Authenticator-managed group membership
|
||||||
|
|
||||||
:::{versionadded} 2.2
|
:::{versionadded} 2.2
|
||||||
@@ -279,8 +281,8 @@ all group-management via the API is disabled.
|
|||||||
|
|
||||||
## pre_spawn_start and post_spawn_stop hooks
|
## pre_spawn_start and post_spawn_stop hooks
|
||||||
|
|
||||||
Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and
|
Authenticators uses two hooks, {meth}`.Authenticator.pre_spawn_start` and
|
||||||
[post_spawn_stop(user, spawner)][] to add pass additional state information
|
{meth}`.Authenticator.post_spawn_stop(user, spawner)` to add pass additional state information
|
||||||
between the authenticator and a spawner. These hooks are typically used auth-related
|
between the authenticator and a spawner. These hooks are typically used auth-related
|
||||||
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
|
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
|
||||||
PAM session.
|
PAM session.
|
||||||
@@ -289,10 +291,7 @@ PAM session.
|
|||||||
|
|
||||||
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||||
|
|
||||||
[authenticator]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/auth.py
|
|
||||||
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||||
[oauth]: https://en.wikipedia.org/wiki/OAuth
|
[oauth]: https://en.wikipedia.org/wiki/OAuth
|
||||||
[github oauth]: https://developer.github.com/v3/oauth/
|
[github oauth]: https://developer.github.com/v3/oauth/
|
||||||
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
|
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||||
[pre_spawn_start(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
|
|
||||||
[post_spawn_stop(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
|
||||||
|
@@ -165,7 +165,7 @@ As with nginx above, you can use [Apache](https://httpd.apache.org) as the rever
|
|||||||
First, we will need to enable the apache modules that we are going to need:
|
First, we will need to enable the apache modules that we are going to need:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
|
a2enmod ssl rewrite proxy headers proxy_http proxy_wstunnel
|
||||||
```
|
```
|
||||||
|
|
||||||
Our Apache configuration is equivalent to the nginx configuration above:
|
Our Apache configuration is equivalent to the nginx configuration above:
|
||||||
@@ -188,13 +188,24 @@ Listen 443
|
|||||||
|
|
||||||
ServerName HUB.DOMAIN.TLD
|
ServerName HUB.DOMAIN.TLD
|
||||||
|
|
||||||
|
# enable HTTP/2, if available
|
||||||
|
Protocols h2 http/1.1
|
||||||
|
|
||||||
|
# HTTP Strict Transport Security (mod_headers is required) (63072000 seconds)
|
||||||
|
Header always set Strict-Transport-Security "max-age=63072000"
|
||||||
|
|
||||||
# configure SSL
|
# configure SSL
|
||||||
SSLEngine on
|
SSLEngine on
|
||||||
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
||||||
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
||||||
SSLProtocol All -SSLv2 -SSLv3
|
|
||||||
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
|
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
|
||||||
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
|
|
||||||
|
# intermediate configuration from ssl-config.mozilla.org (2022-03-03)
|
||||||
|
# Please note, that this configuration might be out-dated - please update it accordingly using https://ssl-config.mozilla.org/
|
||||||
|
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
|
||||||
|
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
|
||||||
|
SSLHonorCipherOrder off
|
||||||
|
SSLSessionTickets off
|
||||||
|
|
||||||
# Use RewriteEngine to handle websocket connection upgrades
|
# Use RewriteEngine to handle websocket connection upgrades
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
@@ -208,6 +219,7 @@ Listen 443
|
|||||||
# proxy to JupyterHub
|
# proxy to JupyterHub
|
||||||
ProxyPass http://127.0.0.1:8000/
|
ProxyPass http://127.0.0.1:8000/
|
||||||
ProxyPassReverse http://127.0.0.1:8000/
|
ProxyPassReverse http://127.0.0.1:8000/
|
||||||
|
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||||
</Location>
|
</Location>
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
@@ -113,7 +113,6 @@ c.JupyterHub.load_roles = [
|
|||||||
"scopes": [
|
"scopes": [
|
||||||
# specify the permissions the token should have
|
# specify the permissions the token should have
|
||||||
"admin:users",
|
"admin:users",
|
||||||
"admin:services",
|
|
||||||
],
|
],
|
||||||
"services": [
|
"services": [
|
||||||
# assign the service the above permissions
|
# assign the service the above permissions
|
||||||
|
@@ -83,6 +83,7 @@ c.JupyterHub.load_roles = [
|
|||||||
# 'admin:users' # needed if culling idle users as well
|
# 'admin:users' # needed if culling idle users as well
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
@@ -208,23 +209,23 @@ can be used by services. You may go beyond this reference implementation and
|
|||||||
create custom hub-authenticating clients and services. We describe the process
|
create custom hub-authenticating clients and services. We describe the process
|
||||||
below.
|
below.
|
||||||
|
|
||||||
The reference, or base, implementation is the [`HubAuth`][hubauth] class,
|
The reference, or base, implementation is the {class}`.HubAuth` class,
|
||||||
which implements the API requests to the Hub that resolve a token to a User model.
|
which implements the API requests to the Hub that resolve a token to a User model.
|
||||||
|
|
||||||
There are two levels of authentication with the Hub:
|
There are two levels of authentication with the Hub:
|
||||||
|
|
||||||
- [`HubAuth`][hubauth] - the most basic authentication,
|
- {class}`.HubAuth` - the most basic authentication,
|
||||||
for services that should only accept API requests authorized with a token.
|
for services that should only accept API requests authorized with a token.
|
||||||
|
|
||||||
- [`HubOAuth`][huboauth] - For services that should use oauth to authenticate with the Hub.
|
- {class}`.HubOAuth` - For services that should use oauth to authenticate with the Hub.
|
||||||
This should be used for any service that serves pages that should be visited with a browser.
|
This should be used for any service that serves pages that should be visited with a browser.
|
||||||
|
|
||||||
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
||||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||||
|
|
||||||
Most of the logic for authentication implementation is found in the
|
Most of the logic for authentication implementation is found in the
|
||||||
[`HubAuth.user_for_token`][hubauth.user_for_token]
|
{meth}`.HubAuth.user_for_token` methods,
|
||||||
methods, which makes a request of the Hub, and returns:
|
which makes a request of the Hub, and returns:
|
||||||
|
|
||||||
- None, if no user could be identified, or
|
- None, if no user could be identified, or
|
||||||
- a dict of the following form:
|
- a dict of the following form:
|
||||||
@@ -245,6 +246,19 @@ action.
|
|||||||
HubAuth also caches the Hub's response for a number of seconds,
|
HubAuth also caches the Hub's response for a number of seconds,
|
||||||
configurable by the `cookie_cache_max_age` setting (default: five minutes).
|
configurable by the `cookie_cache_max_age` setting (default: five minutes).
|
||||||
|
|
||||||
|
If your service would like to make further requests _on behalf of users_,
|
||||||
|
it should use the token issued by this OAuth process.
|
||||||
|
If you are using tornado,
|
||||||
|
you can access the token authenticating the current request with {meth}`.HubAuth.get_token`.
|
||||||
|
|
||||||
|
:::{versionchanged} 2.2
|
||||||
|
|
||||||
|
{meth}`.HubAuth.get_token` adds support for retrieving
|
||||||
|
tokens stored in tornado cookies after completion of OAuth.
|
||||||
|
Previously, it only retrieved tokens from URL parameters or the Authorization header.
|
||||||
|
Passing `get_token(handler, in_cookie=False)` preserves this behavior.
|
||||||
|
:::
|
||||||
|
|
||||||
### Flask Example
|
### Flask Example
|
||||||
|
|
||||||
For example, you have a Flask service that returns information about a user.
|
For example, you have a Flask service that returns information about a user.
|
||||||
@@ -370,11 +384,6 @@ section on securing the notebook viewer.
|
|||||||
|
|
||||||
[requests]: http://docs.python-requests.org/en/master/
|
[requests]: http://docs.python-requests.org/en/master/
|
||||||
[services_auth]: ../api/services.auth.html
|
[services_auth]: ../api/services.auth.html
|
||||||
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
|
||||||
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
|
|
||||||
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
|
||||||
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
|
||||||
[huboauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuthenticated
|
|
||||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||||
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
||||||
[fastapi]: https://fastapi.tiangolo.com
|
[fastapi]: https://fastapi.tiangolo.com
|
||||||
|
@@ -275,7 +275,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key.
|
|||||||
|
|
||||||
Then restart JupyterHub.
|
Then restart JupyterHub.
|
||||||
|
|
||||||
See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption).
|
See also {ref}`ssl-encryption`.
|
||||||
|
|
||||||
### Install JupyterHub without a network connection
|
### Install JupyterHub without a network connection
|
||||||
|
|
||||||
|
135
examples/custom-scopes/grades.py
Normal file
135
examples/custom-scopes/grades.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
|
from html import escape
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from tornado.httpserver import HTTPServer
|
||||||
|
from tornado.ioloop import IOLoop
|
||||||
|
from tornado.web import Application
|
||||||
|
from tornado.web import authenticated
|
||||||
|
from tornado.web import RequestHandler
|
||||||
|
|
||||||
|
from jupyterhub.services.auth import HubOAuthCallbackHandler
|
||||||
|
from jupyterhub.services.auth import HubOAuthenticated
|
||||||
|
from jupyterhub.utils import url_path_join
|
||||||
|
|
||||||
|
SCOPE_PREFIX = "custom:grades"
|
||||||
|
READ_SCOPE = f"{SCOPE_PREFIX}:read"
|
||||||
|
WRITE_SCOPE = f"{SCOPE_PREFIX}:write"
|
||||||
|
|
||||||
|
|
||||||
|
def require_scope(scopes):
|
||||||
|
"""Decorator to require scopes
|
||||||
|
|
||||||
|
For use if multiple methods on one Handler
|
||||||
|
may want different scopes,
|
||||||
|
so class-level .hub_scopes is insufficient
|
||||||
|
(e.g. read for GET, write for POST).
|
||||||
|
"""
|
||||||
|
if isinstance(scopes, str):
|
||||||
|
scopes = [scopes]
|
||||||
|
|
||||||
|
def wrap(method):
|
||||||
|
"""The actual decorator"""
|
||||||
|
|
||||||
|
@wraps(method)
|
||||||
|
@authenticated
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
self.hub_scopes = scopes
|
||||||
|
return method(self, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
class MyGradesHandler(HubOAuthenticated, RequestHandler):
|
||||||
|
# no hub_scopes, anyone with access to this service
|
||||||
|
# will be able to visit this URL
|
||||||
|
|
||||||
|
@authenticated
|
||||||
|
def get(self):
|
||||||
|
self.write("<h1>My grade</h1>")
|
||||||
|
name = self.current_user["name"]
|
||||||
|
grades = self.settings["grades"]
|
||||||
|
self.write(f"<p>My name is: {escape(name)}</p>")
|
||||||
|
if name in grades:
|
||||||
|
self.write(f"<p>My grade is: {escape(str(grades[name]))}</p>")
|
||||||
|
else:
|
||||||
|
self.write("<p>No grade entered</p>")
|
||||||
|
if READ_SCOPE in self.current_user["scopes"]:
|
||||||
|
self.write('<a href="grades/">enter grades</a>')
|
||||||
|
|
||||||
|
|
||||||
|
class GradesHandler(HubOAuthenticated, RequestHandler):
|
||||||
|
# default scope for this Handler: read-only
|
||||||
|
hub_scopes = [READ_SCOPE]
|
||||||
|
|
||||||
|
def _render(self):
|
||||||
|
grades = self.settings["grades"]
|
||||||
|
self.write("<h1>All grades</h1>")
|
||||||
|
self.write("<table>")
|
||||||
|
self.write("<tr><th>Student</th><th>Grade</th></tr>")
|
||||||
|
for student, grade in grades.items():
|
||||||
|
qstudent = escape(student)
|
||||||
|
qgrade = escape(str(grade))
|
||||||
|
self.write(
|
||||||
|
f"""
|
||||||
|
<tr>
|
||||||
|
<td class="student">{qstudent}</td>
|
||||||
|
<td class="grade">{qgrade}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if WRITE_SCOPE in self.current_user["scopes"]:
|
||||||
|
self.write("Enter grade:")
|
||||||
|
self.write(
|
||||||
|
"""
|
||||||
|
<form action=. method=POST>
|
||||||
|
<input name=student placeholder=student></input>
|
||||||
|
<input kind=number name=grade placeholder=grade></input>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@require_scope([READ_SCOPE])
|
||||||
|
async def get(self):
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
# POST requires WRITE_SCOPE instead of READ_SCOPE
|
||||||
|
@require_scope([WRITE_SCOPE])
|
||||||
|
async def post(self):
|
||||||
|
name = self.get_argument("student")
|
||||||
|
grade = self.get_argument("grade")
|
||||||
|
self.settings["grades"][name] = grade
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base_url = os.environ['JUPYTERHUB_SERVICE_PREFIX']
|
||||||
|
|
||||||
|
app = Application(
|
||||||
|
[
|
||||||
|
(base_url, MyGradesHandler),
|
||||||
|
(url_path_join(base_url, 'grades/'), GradesHandler),
|
||||||
|
(
|
||||||
|
url_path_join(base_url, 'oauth_callback'),
|
||||||
|
HubOAuthCallbackHandler,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
cookie_secret=os.urandom(32),
|
||||||
|
grades={"student": 53},
|
||||||
|
)
|
||||||
|
|
||||||
|
http_server = HTTPServer(app)
|
||||||
|
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||||
|
|
||||||
|
http_server.listen(url.port, url.hostname)
|
||||||
|
try:
|
||||||
|
IOLoop.current().start()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
49
examples/custom-scopes/jupyterhub_config.py
Normal file
49
examples/custom-scopes/jupyterhub_config.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
c = get_config() # noqa
|
||||||
|
|
||||||
|
c.JupyterHub.services = [
|
||||||
|
{
|
||||||
|
'name': 'grades',
|
||||||
|
'url': 'http://127.0.0.1:10101',
|
||||||
|
'command': [sys.executable, './grades.py'],
|
||||||
|
'oauth_roles': ['grader'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
c.JupyterHub.custom_scopes = {
|
||||||
|
"custom:grades:read": {
|
||||||
|
"description": "read-access to all grades",
|
||||||
|
},
|
||||||
|
"custom:grades:write": {
|
||||||
|
"description": "Enter new grades",
|
||||||
|
"subscopes": ["custom:grades:read"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "user",
|
||||||
|
# grant all users access to services
|
||||||
|
"scopes": ["access:services", "self"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "grader",
|
||||||
|
# grant graders access to write grades
|
||||||
|
"scopes": ["custom:grades:write"],
|
||||||
|
"users": ["grader"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "instructor",
|
||||||
|
# grant instructors access to read, but not write grades
|
||||||
|
"scopes": ["custom:grades:read"],
|
||||||
|
"users": ["instructor"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
c.JupyterHub.allowed_users = {"instructor", "grader", "student"}
|
||||||
|
# dummy spawner and authenticator for testing, don't actually use these!
|
||||||
|
c.JupyterHub.authenticator_class = 'dummy'
|
||||||
|
c.JupyterHub.spawner_class = 'simple'
|
||||||
|
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||||
|
c.JupyterHub.log_level = 10
|
@@ -5,12 +5,12 @@ object-assign
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/*!
|
/*!
|
||||||
Copyright (c) 2017 Jed Watson.
|
Copyright (c) 2018 Jed Watson.
|
||||||
Licensed under the MIT License (MIT), see
|
Licensed under the MIT License (MIT), see
|
||||||
http://jedwatson.github.io/classnames
|
http://jedwatson.github.io/classnames
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @license React v0.20.1
|
/** @license React v0.20.2
|
||||||
* scheduler.production.min.js
|
* scheduler.production.min.js
|
||||||
*
|
*
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
@@ -28,7 +28,7 @@ object-assign
|
|||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @license React v17.0.1
|
/** @license React v17.0.2
|
||||||
* react-dom.production.min.js
|
* react-dom.production.min.js
|
||||||
*
|
*
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
@@ -37,7 +37,7 @@ object-assign
|
|||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @license React v17.0.1
|
/** @license React v17.0.2
|
||||||
* react.production.min.js
|
* react.production.min.js
|
||||||
*
|
*
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
@@ -40,6 +40,7 @@
|
|||||||
"eslint-plugin-unused-imports": "^1.1.1",
|
"eslint-plugin-unused-imports": "^1.1.1",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"history": "^5.0.0",
|
"history": "^5.0.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-bootstrap": "^1.4.0",
|
"react-bootstrap": "^1.4.0",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"recompose": "^0.30.0",
|
"recompose": "^0.30.0",
|
||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
|
"regenerator-runtime": "^0.13.9",
|
||||||
"style-loader": "^2.0.0",
|
"style-loader": "^2.0.0",
|
||||||
"webpack": "^5.6.0",
|
"webpack": "^5.6.0",
|
||||||
"webpack-cli": "^3.3.4",
|
"webpack-cli": "^3.3.4",
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
||||||
|
"sinon": "^13.0.1",
|
||||||
"babel-jest": "^26.6.3",
|
"babel-jest": "^26.6.3",
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"eslint": "^7.18.0",
|
"eslint": "^7.18.0",
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
export const initialState = {
|
export const initialState = {
|
||||||
user_data: undefined,
|
user_data: undefined,
|
||||||
user_page: 0,
|
user_page: 0,
|
||||||
|
name_filter: "",
|
||||||
groups_data: undefined,
|
groups_data: undefined,
|
||||||
groups_page: 0,
|
groups_page: 0,
|
||||||
limit: window.api_page_limit,
|
limit: window.api_page_limit,
|
||||||
@@ -13,6 +14,7 @@ export const reducers = (state = initialState, action) => {
|
|||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
user_page: action.value.page,
|
user_page: action.value.page,
|
||||||
user_data: action.value.data,
|
user_data: action.value.data,
|
||||||
|
name_filter: action.value.name_filter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Updates the client group model data and stores the page
|
// Updates the client group model data and stores the page
|
||||||
|
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import { Button } from "react-bootstrap";
|
import { Button, Col, Row, FormControl } from "react-bootstrap";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||||
|
|
||||||
@@ -10,8 +11,8 @@ import "./server-dashboard.css";
|
|||||||
import { timeSince } from "../../util/timeSince";
|
import { timeSince } from "../../util/timeSince";
|
||||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||||
|
|
||||||
const AccessServerButton = ({ userName, serverName }) => (
|
const AccessServerButton = ({ url }) => (
|
||||||
<a href={`/user/${userName}/${serverName || ""}`}>
|
<a href={url || ""}>
|
||||||
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
|
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
|
||||||
Access Server
|
Access Server
|
||||||
</button>
|
</button>
|
||||||
@@ -19,6 +20,7 @@ const AccessServerButton = ({ userName, serverName }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ServerDashboard = (props) => {
|
const ServerDashboard = (props) => {
|
||||||
|
let base_url = window.base_url;
|
||||||
// sort methods
|
// sort methods
|
||||||
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
||||||
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
|
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
|
||||||
@@ -42,10 +44,11 @@ const ServerDashboard = (props) => {
|
|||||||
var user_data = useSelector((state) => state.user_data),
|
var user_data = useSelector((state) => state.user_data),
|
||||||
user_page = useSelector((state) => state.user_page),
|
user_page = useSelector((state) => state.user_page),
|
||||||
limit = useSelector((state) => state.limit),
|
limit = useSelector((state) => state.limit),
|
||||||
|
name_filter = useSelector((state) => state.name_filter),
|
||||||
page = parseInt(new URLSearchParams(props.location.search).get("page"));
|
page = parseInt(new URLSearchParams(props.location.search).get("page"));
|
||||||
|
|
||||||
page = isNaN(page) ? 0 : page;
|
page = isNaN(page) ? 0 : page;
|
||||||
var slice = [page * limit, limit];
|
var slice = [page * limit, limit, name_filter];
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
@@ -59,12 +62,13 @@ const ServerDashboard = (props) => {
|
|||||||
history,
|
history,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
var dispatchPageUpdate = (data, page) => {
|
var dispatchPageUpdate = (data, page, name_filter) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "USER_PAGE",
|
type: "USER_PAGE",
|
||||||
value: {
|
value: {
|
||||||
data: data,
|
data: data,
|
||||||
page: page,
|
page: page,
|
||||||
|
name_filter: name_filter,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -74,9 +78,19 @@ const ServerDashboard = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (page != user_page) {
|
if (page != user_page) {
|
||||||
updateUsers(...slice).then((data) => dispatchPageUpdate(data, page));
|
updateUsers(...slice).then((data) =>
|
||||||
|
dispatchPageUpdate(data, page, name_filter)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debounce = require("lodash.debounce");
|
||||||
|
const handleSearch = debounce(async (event) => {
|
||||||
|
// setNameFilter(event.target.value);
|
||||||
|
updateUsers(page * limit, limit, event.target.value).then((data) =>
|
||||||
|
dispatchPageUpdate(data, page, name_filter)
|
||||||
|
);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
if (sortMethod != null) {
|
if (sortMethod != null) {
|
||||||
user_data = sortMethod(user_data);
|
user_data = sortMethod(user_data);
|
||||||
}
|
}
|
||||||
@@ -94,7 +108,7 @@ const ServerDashboard = (props) => {
|
|||||||
if (res.status < 300) {
|
if (res.status < 300) {
|
||||||
updateUsers(...slice)
|
updateUsers(...slice)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data, page);
|
dispatchPageUpdate(data, page, name_filter);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setIsDisabled(false);
|
setIsDisabled(false);
|
||||||
@@ -130,7 +144,7 @@ const ServerDashboard = (props) => {
|
|||||||
if (res.status < 300) {
|
if (res.status < 300) {
|
||||||
updateUsers(...slice)
|
updateUsers(...slice)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data, page);
|
dispatchPageUpdate(data, page, name_filter);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setErrorAlert(`Failed to update users list.`);
|
setErrorAlert(`Failed to update users list.`);
|
||||||
@@ -153,10 +167,9 @@ const ServerDashboard = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditUserCell = ({ user, numServers, serverName }) => {
|
const EditUserCell = ({ user }) => {
|
||||||
if (serverName) return null;
|
|
||||||
return (
|
return (
|
||||||
<td rowspan={numServers}>
|
<td>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-xs"
|
className="btn btn-primary btn-xs"
|
||||||
style={{ marginRight: 20 }}
|
style={{ marginRight: 20 }}
|
||||||
@@ -176,6 +189,14 @@ const ServerDashboard = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let servers = user_data.flatMap((user) => {
|
||||||
|
let userServers = Object.values({
|
||||||
|
"": user.server || {},
|
||||||
|
...(user.servers || {}),
|
||||||
|
});
|
||||||
|
return userServers.map((server) => [user, server]);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" data-testid="container">
|
<div className="container" data-testid="container">
|
||||||
{errorAlert != null ? (
|
{errorAlert != null ? (
|
||||||
@@ -196,10 +217,23 @@ const ServerDashboard = (props) => {
|
|||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
<div className="manage-groups" style={{ float: "right", margin: "20px" }}>
|
|
||||||
<Link to="/groups">{"> Manage Groups"}</Link>
|
|
||||||
</div>
|
|
||||||
<div className="server-dashboard-container">
|
<div className="server-dashboard-container">
|
||||||
|
<Row>
|
||||||
|
<Col md={4}>
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
name="user_search"
|
||||||
|
placeholder="Search users"
|
||||||
|
aria-label="user-search"
|
||||||
|
value={name_filter}
|
||||||
|
onChange={handleSearch}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col md="auto" style={{ float: "right", margin: 15 }}>
|
||||||
|
<Link to="/groups">{"> Manage Groups"}</Link>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
<table className="table table-striped table-bordered table-hover">
|
<table className="table table-striped table-bordered table-hover">
|
||||||
<thead className="admin-table-head">
|
<thead className="admin-table-head">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -279,7 +313,7 @@ const ServerDashboard = (props) => {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
updateUsers(...slice)
|
updateUsers(...slice)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data, page);
|
dispatchPageUpdate(data, page, name_filter);
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
setErrorAlert(`Failed to update users list.`)
|
setErrorAlert(`Failed to update users list.`)
|
||||||
@@ -315,7 +349,7 @@ const ServerDashboard = (props) => {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
updateUsers(...slice)
|
updateUsers(...slice)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data, page);
|
dispatchPageUpdate(data, page, name_filter);
|
||||||
})
|
})
|
||||||
.catch(() =>
|
.catch(() =>
|
||||||
setErrorAlert(`Failed to update users list.`)
|
setErrorAlert(`Failed to update users list.`)
|
||||||
@@ -339,31 +373,14 @@ const ServerDashboard = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{user_data.flatMap((e, i) => {
|
{servers.map(([user, server], i) => {
|
||||||
let userServers = Object.values({
|
server.name = server.name || "";
|
||||||
"": e.server,
|
|
||||||
...(e.servers || {}),
|
|
||||||
});
|
|
||||||
return userServers.map((server) => {
|
|
||||||
server = { name: "", ...server };
|
|
||||||
return (
|
return (
|
||||||
<tr key={i + "row"} className="user-row">
|
<tr key={i + "row"} className="user-row">
|
||||||
{!server.name && (
|
<td data-testid="user-row-name">{user.name}</td>
|
||||||
<td
|
<td data-testid="user-row-admin">
|
||||||
data-testid="user-row-name"
|
{user.admin ? "admin" : ""}
|
||||||
rowspan={userServers.length}
|
|
||||||
>
|
|
||||||
{e.name}
|
|
||||||
</td>
|
</td>
|
||||||
)}
|
|
||||||
{!server.name && (
|
|
||||||
<td
|
|
||||||
data-testid="user-row-admin"
|
|
||||||
rowspan={userServers.length}
|
|
||||||
>
|
|
||||||
{e.admin ? "admin" : ""}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<td data-testid="user-row-server">
|
<td data-testid="user-row-server">
|
||||||
{server.name ? (
|
{server.name ? (
|
||||||
@@ -383,22 +400,19 @@ const ServerDashboard = (props) => {
|
|||||||
<>
|
<>
|
||||||
<StopServerButton
|
<StopServerButton
|
||||||
serverName={server.name}
|
serverName={server.name}
|
||||||
userName={e.name}
|
userName={user.name}
|
||||||
/>
|
|
||||||
<AccessServerButton
|
|
||||||
serverName={server.name}
|
|
||||||
userName={e.name}
|
|
||||||
/>
|
/>
|
||||||
|
<AccessServerButton url={server.url} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
// Start Single-user server
|
// Start Single-user server
|
||||||
<>
|
<>
|
||||||
<StartServerButton
|
<StartServerButton
|
||||||
serverName={server.name}
|
serverName={server.name}
|
||||||
userName={e.name}
|
userName={user.name}
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
href={`/spawn/${e.name}${
|
href={`${base_url}spawn/${user.name}${
|
||||||
server.name && "/" + server.name
|
server.name && "/" + server.name
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -412,14 +426,9 @@ const ServerDashboard = (props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<EditUserCell
|
<EditUserCell user={user} />
|
||||||
user={e}
|
|
||||||
numServers={userServers.length}
|
|
||||||
serverName={server.name}
|
|
||||||
/>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
});
|
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@@ -9,6 +9,9 @@ import { createStore } from "redux";
|
|||||||
import regeneratorRuntime from "regenerator-runtime";
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
import ServerDashboard from "./ServerDashboard";
|
import ServerDashboard from "./ServerDashboard";
|
||||||
|
import * as sinon from "sinon";
|
||||||
|
|
||||||
|
let clock;
|
||||||
|
|
||||||
jest.mock("react-redux", () => ({
|
jest.mock("react-redux", () => ({
|
||||||
...jest.requireActual("react-redux"),
|
...jest.requireActual("react-redux"),
|
||||||
@@ -45,6 +48,7 @@ var mockAppState = () => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clock = sinon.useFakeTimers();
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback(mockAppState());
|
return callback(mockAppState());
|
||||||
});
|
});
|
||||||
@@ -52,6 +56,7 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useSelector.mockClear();
|
useSelector.mockClear();
|
||||||
|
clock.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Renders", async () => {
|
test("Renders", async () => {
|
||||||
@@ -435,3 +440,42 @@ test("Shows a UI error dialogue when stop user server returns an improper status
|
|||||||
|
|
||||||
expect(errorDialog).toBeVisible();
|
expect(errorDialog).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Search for user calls updateUsers with name filter", async () => {
|
||||||
|
let spy = mockAsync();
|
||||||
|
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<Provider store={createStore(() => {}, {})}>
|
||||||
|
<HashRouter>
|
||||||
|
<Switch>
|
||||||
|
<ServerDashboard
|
||||||
|
updateUsers={mockUpdateUsers}
|
||||||
|
shutdownHub={spy}
|
||||||
|
startServer={spy}
|
||||||
|
stopServer={spy}
|
||||||
|
startAll={spy}
|
||||||
|
stopAll={spy}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</HashRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let search = screen.getByLabelText("user-search");
|
||||||
|
|
||||||
|
fireEvent.change(search, { target: { value: "a" } });
|
||||||
|
clock.tick(400);
|
||||||
|
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
||||||
|
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
|
||||||
|
expect(search.value).toEqual("a");
|
||||||
|
|
||||||
|
fireEvent.change(search, { target: { value: "ab" } });
|
||||||
|
clock.tick(400);
|
||||||
|
expect(mockUpdateUsers.mock.calls).toHaveLength(3);
|
||||||
|
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab");
|
||||||
|
expect(search.value).toEqual("ab");
|
||||||
|
});
|
||||||
|
@@ -2,10 +2,11 @@ import { withProps } from "recompose";
|
|||||||
import { jhapiRequest } from "./jhapiUtil";
|
import { jhapiRequest } from "./jhapiUtil";
|
||||||
|
|
||||||
const withAPI = withProps(() => ({
|
const withAPI = withProps(() => ({
|
||||||
updateUsers: (offset, limit) =>
|
updateUsers: (offset, limit, name_filter) =>
|
||||||
jhapiRequest(`/users?offset=${offset}&limit=${limit}`, "GET").then((data) =>
|
jhapiRequest(
|
||||||
data.json()
|
`/users?offset=${offset}&limit=${limit}&name_filter=${name_filter}`,
|
||||||
),
|
"GET"
|
||||||
|
).then((data) => data.json()),
|
||||||
updateGroups: (offset, limit) =>
|
updateGroups: (offset, limit) =>
|
||||||
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
|
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
|
||||||
(data) => data.json()
|
(data) => data.json()
|
||||||
|
4797
jsx/yarn.lock
4797
jsx/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
# version_info updated by running `tbump`
|
# version_info updated by running `tbump`
|
||||||
version_info = (2, 2, 0, "", "dev")
|
version_info = (2, 3, 0, "", "dev")
|
||||||
|
|
||||||
# pep 440 version: no dot before beta/rc, but before .dev
|
# pep 440 version: no dot before beta/rc, but before .dev
|
||||||
# 0.1.0rc1
|
# 0.1.0rc1
|
||||||
|
@@ -283,6 +283,21 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
raise web.HTTPError(
|
raise web.HTTPError(
|
||||||
403, f"You do not have permission to access {client.description}"
|
403, f"You do not have permission to access {client.description}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# subset role names to those held by authenticating user
|
||||||
|
requested_role_names = set(role_names)
|
||||||
|
user = self.current_user
|
||||||
|
user_role_names = {role.name for role in user.roles}
|
||||||
|
allowed_role_names = requested_role_names.intersection(user_role_names)
|
||||||
|
excluded_role_names = requested_role_names.difference(allowed_role_names)
|
||||||
|
if excluded_role_names:
|
||||||
|
self.log.info(
|
||||||
|
f"Service {client.description} requested roles {','.join(role_names)}"
|
||||||
|
f" for user {self.current_user.name},"
|
||||||
|
f" granting only {','.join(allowed_role_names) or '[]'}."
|
||||||
|
)
|
||||||
|
role_names = list(allowed_role_names)
|
||||||
|
|
||||||
if not self.needs_oauth_confirm(self.current_user, client, role_names):
|
if not self.needs_oauth_confirm(self.current_user, client, role_names):
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Skipping oauth confirmation for %s accessing %s",
|
"Skipping oauth confirmation for %s accessing %s",
|
||||||
@@ -381,6 +396,10 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
|||||||
# The scopes the user actually authorized, i.e. checkboxes
|
# The scopes the user actually authorized, i.e. checkboxes
|
||||||
# that were selected.
|
# that were selected.
|
||||||
scopes = self.get_arguments('scopes')
|
scopes = self.get_arguments('scopes')
|
||||||
|
if scopes == []:
|
||||||
|
# avoid triggering default scopes (provider selects default scopes when scopes is falsy)
|
||||||
|
# when an explicit empty list is authorized
|
||||||
|
scopes = ["identify"]
|
||||||
# credentials we need in the validator
|
# credentials we need in the validator
|
||||||
credentials = self.add_credentials()
|
credentials = self.add_credentials()
|
||||||
|
|
||||||
|
@@ -84,6 +84,7 @@ class UserListAPIHandler(APIHandler):
|
|||||||
@needs_scope('list:users')
|
@needs_scope('list:users')
|
||||||
def get(self):
|
def get(self):
|
||||||
state_filter = self.get_argument("state", None)
|
state_filter = self.get_argument("state", None)
|
||||||
|
name_filter = self.get_argument("name_filter", None)
|
||||||
offset, limit = self.get_api_pagination()
|
offset, limit = self.get_api_pagination()
|
||||||
|
|
||||||
# post_filter
|
# post_filter
|
||||||
@@ -148,6 +149,9 @@ class UserListAPIHandler(APIHandler):
|
|||||||
else:
|
else:
|
||||||
query = query.filter(or_(*filters))
|
query = query.filter(or_(*filters))
|
||||||
|
|
||||||
|
if name_filter:
|
||||||
|
query = query.filter(orm.User.name.ilike(f'%{name_filter}%'))
|
||||||
|
|
||||||
full_query = query
|
full_query = query
|
||||||
query = query.order_by(orm.User.id.asc()).offset(offset).limit(limit)
|
query = query.order_by(orm.User.id.asc()).offset(offset).limit(limit)
|
||||||
|
|
||||||
@@ -515,7 +519,7 @@ class UserServerAPIHandler(APIHandler):
|
|||||||
user_name, self.named_server_limit_per_user
|
user_name, self.named_server_limit_per_user
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.get_spawner(server_name, replace_failed=True)
|
||||||
pending = spawner.pending
|
pending = spawner.pending
|
||||||
if pending == 'spawn':
|
if pending == 'spawn':
|
||||||
self.set_header('Content-Type', 'text/plain')
|
self.set_header('Content-Type', 'text/plain')
|
||||||
|
@@ -17,10 +17,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from functools import partial
|
|
||||||
from getpass import getuser
|
from getpass import getuser
|
||||||
from glob import glob
|
|
||||||
from itertools import chain
|
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
@@ -54,7 +51,6 @@ from traitlets import (
|
|||||||
Unicode,
|
Unicode,
|
||||||
Integer,
|
Integer,
|
||||||
Dict,
|
Dict,
|
||||||
TraitError,
|
|
||||||
List,
|
List,
|
||||||
Bool,
|
Bool,
|
||||||
Any,
|
Any,
|
||||||
@@ -81,8 +77,10 @@ from .handlers.static import CacheControlStaticFilesHandler, LogoHandler
|
|||||||
from .services.service import Service
|
from .services.service import Service
|
||||||
|
|
||||||
from . import crypto
|
from . import crypto
|
||||||
from . import dbutil, orm
|
from . import dbutil
|
||||||
|
from . import orm
|
||||||
from . import roles
|
from . import roles
|
||||||
|
from . import scopes
|
||||||
from .user import UserDict
|
from .user import UserDict
|
||||||
from .oauth.provider import make_provider
|
from .oauth.provider import make_provider
|
||||||
from ._data import DATA_FILES_PATH
|
from ._data import DATA_FILES_PATH
|
||||||
@@ -353,6 +351,29 @@ class JupyterHub(Application):
|
|||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
custom_scopes = Dict(
|
||||||
|
key_trait=Unicode(),
|
||||||
|
value_trait=Dict(
|
||||||
|
key_trait=Unicode(),
|
||||||
|
),
|
||||||
|
help="""Custom scopes to define.
|
||||||
|
|
||||||
|
For use when defining custom roles,
|
||||||
|
to grant users granular permissions
|
||||||
|
|
||||||
|
All custom scopes must have a description,
|
||||||
|
and must start with the prefix `custom:`.
|
||||||
|
|
||||||
|
For example::
|
||||||
|
|
||||||
|
custom_scopes = {
|
||||||
|
"custom:jupyter_server:read": {
|
||||||
|
"description": "read-only access to a single-user server",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
config_file = Unicode('jupyterhub_config.py', help="The config file to load").tag(
|
config_file = Unicode('jupyterhub_config.py', help="The config file to load").tag(
|
||||||
config=True
|
config=True
|
||||||
)
|
)
|
||||||
@@ -2018,7 +2039,10 @@ class JupyterHub(Application):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
async def init_role_creation(self):
|
async def init_role_creation(self):
|
||||||
"""Load default and predefined roles into the database"""
|
"""Load default and user-defined roles and scopes into the database"""
|
||||||
|
if self.custom_scopes:
|
||||||
|
self.log.info(f"Defining {len(self.custom_scopes)} custom scopes.")
|
||||||
|
scopes.define_custom_scopes(self.custom_scopes)
|
||||||
self.log.debug('Loading roles into database')
|
self.log.debug('Loading roles into database')
|
||||||
default_roles = roles.get_default_roles()
|
default_roles = roles.get_default_roles()
|
||||||
config_role_names = [r['name'] for r in self.load_roles]
|
config_role_names = [r['name'] for r in self.load_roles]
|
||||||
|
@@ -526,10 +526,16 @@ class BaseHandler(RequestHandler):
|
|||||||
path=url_path_join(self.base_url, 'services'),
|
path=url_path_join(self.base_url, 'services'),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
# clear tornado cookie
|
# clear_cookie only accepts a subset of set_cookie's kwargs
|
||||||
|
clear_xsrf_cookie_kwargs = {
|
||||||
|
key: value
|
||||||
|
for key, value in self.settings.get('xsrf_cookie_kwargs', {})
|
||||||
|
if key in {"path", "domain"}
|
||||||
|
}
|
||||||
|
|
||||||
self.clear_cookie(
|
self.clear_cookie(
|
||||||
'_xsrf',
|
'_xsrf',
|
||||||
**self.settings.get('xsrf_cookie_kwargs', {}),
|
**clear_xsrf_cookie_kwargs,
|
||||||
)
|
)
|
||||||
# Reset _jupyterhub_user
|
# Reset _jupyterhub_user
|
||||||
self._jupyterhub_user = None
|
self._jupyterhub_user = None
|
||||||
|
@@ -151,7 +151,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
self.redirect(url)
|
self.redirect(url)
|
||||||
return
|
return
|
||||||
|
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.get_spawner(server_name, replace_failed=True)
|
||||||
|
|
||||||
pending_url = self._get_pending_url(user, server_name)
|
pending_url = self._get_pending_url(user, server_name)
|
||||||
|
|
||||||
@@ -237,7 +237,7 @@ class SpawnHandler(BaseHandler):
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(404, "No such user: %s" % for_user)
|
raise web.HTTPError(404, "No such user: %s" % for_user)
|
||||||
|
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.get_spawner(server_name, replace_failed=True)
|
||||||
|
|
||||||
if spawner.ready:
|
if spawner.ready:
|
||||||
raise web.HTTPError(400, "%s is already running" % (spawner._log_name))
|
raise web.HTTPError(400, "%s is already running" % (spawner._log_name))
|
||||||
@@ -369,13 +369,9 @@ class SpawnPendingHandler(BaseHandler):
|
|||||||
auth_state = await user.get_auth_state()
|
auth_state = await user.get_auth_state()
|
||||||
|
|
||||||
# First, check for previous failure.
|
# First, check for previous failure.
|
||||||
if (
|
if not spawner.active and spawner._failed:
|
||||||
not spawner.active
|
# Condition: spawner not active and last spawn failed
|
||||||
and spawner._spawn_future
|
# (failure is available as spawner._spawn_future.exception()).
|
||||||
and spawner._spawn_future.done()
|
|
||||||
and spawner._spawn_future.exception()
|
|
||||||
):
|
|
||||||
# Condition: spawner not active and _spawn_future exists and contains an Exception
|
|
||||||
# Implicit spawn on /user/:name is not allowed if the user's last spawn failed.
|
# Implicit spawn on /user/:name is not allowed if the user's last spawn failed.
|
||||||
# We should point the user to Home if the most recent spawn failed.
|
# We should point the user to Home if the most recent spawn failed.
|
||||||
exc = spawner._spawn_future.exception()
|
exc = spawner._spawn_future.exception()
|
||||||
|
@@ -11,9 +11,11 @@ identify scopes: set of expanded scopes needed for identify (whoami) endpoints
|
|||||||
"""
|
"""
|
||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
|
import re
|
||||||
import warnings
|
import warnings
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from textwrap import indent
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from tornado import web
|
from tornado import web
|
||||||
@@ -629,3 +631,120 @@ def describe_raw_scopes(raw_scopes, username=None):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return descriptions
|
return descriptions
|
||||||
|
|
||||||
|
|
||||||
|
# regex for custom scope
|
||||||
|
# for-humans description below
|
||||||
|
# note: scope description duplicated in docs/source/rbac/scopes.md
|
||||||
|
# update docs when making changes here
|
||||||
|
_custom_scope_pattern = re.compile(r"^custom:[a-z0-9][a-z0-9_\-\*:]+[a-z0-9_\*]$")
|
||||||
|
|
||||||
|
# custom scope pattern description
|
||||||
|
# used in docstring below and error message when scopes don't match _custom_scope_pattern
|
||||||
|
_custom_scope_description = """
|
||||||
|
Custom scopes must start with `custom:`
|
||||||
|
and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk (-_:*).
|
||||||
|
The part after `custom:` must start with a letter or number.
|
||||||
|
Scopes may not end with a hyphen or colon.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def define_custom_scopes(scopes):
|
||||||
|
"""Define custom scopes
|
||||||
|
|
||||||
|
Adds custom scopes to the scope_definitions dict.
|
||||||
|
|
||||||
|
Scopes must start with `custom:`.
|
||||||
|
It is recommended to name custom scopes with a pattern like::
|
||||||
|
|
||||||
|
custom:$your-project:$action:$resource
|
||||||
|
|
||||||
|
e.g.::
|
||||||
|
|
||||||
|
custom:jupyter_server:read:contents
|
||||||
|
|
||||||
|
That makes them easy to parse and avoids collisions across projects.
|
||||||
|
|
||||||
|
`scopes` must have at least one scope definition,
|
||||||
|
and each scope definition must have a `description`,
|
||||||
|
which will be displayed on the oauth authorization page,
|
||||||
|
and _may_ have a `subscopes` list of other scopes if having one scope
|
||||||
|
should imply having other, more specific scopes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
|
||||||
|
scopes: dict
|
||||||
|
A dictionary of scope definitions.
|
||||||
|
The keys are the scopes,
|
||||||
|
while the values are dictionaries with at least a `description` field,
|
||||||
|
and optional `subscopes` field.
|
||||||
|
%s
|
||||||
|
Examples::
|
||||||
|
|
||||||
|
define_custom_scopes(
|
||||||
|
{
|
||||||
|
"custom:jupyter_server:read:contents": {
|
||||||
|
"description": "read-only access to files in a Jupyter server",
|
||||||
|
},
|
||||||
|
"custom:jupyter_server:read": {
|
||||||
|
"description": "read-only access to a Jupyter server",
|
||||||
|
"subscopes": [
|
||||||
|
"custom:jupyter_server:read:contents",
|
||||||
|
"custom:jupyter_server:read:kernels",
|
||||||
|
"...",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
""" % indent(
|
||||||
|
_custom_scope_description, " " * 8
|
||||||
|
)
|
||||||
|
for scope, scope_definition in scopes.items():
|
||||||
|
if scope in scope_definitions and scope_definitions[scope] != scope_definition:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot redefine scope {scope}={scope_definition}. Already have {scope}={scope_definitions[scope]}"
|
||||||
|
)
|
||||||
|
if not _custom_scope_pattern.match(scope):
|
||||||
|
# note: keep this description in sync with docstring above
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid scope name: {scope!r}.\n{_custom_scope_description}"
|
||||||
|
" and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk."
|
||||||
|
" The part after `custom:` must start with a letter or number."
|
||||||
|
" Scopes may not end with a hyphen or colon."
|
||||||
|
)
|
||||||
|
if "description" not in scope_definition:
|
||||||
|
raise ValueError(
|
||||||
|
f"scope {scope}={scope_definition} missing key 'description'"
|
||||||
|
)
|
||||||
|
if "subscopes" in scope_definition:
|
||||||
|
subscopes = scope_definition["subscopes"]
|
||||||
|
if not isinstance(subscopes, list) or not all(
|
||||||
|
isinstance(s, str) for s in subscopes
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"subscopes must be a list of scope strings, got {subscopes!r}"
|
||||||
|
)
|
||||||
|
for subscope in subscopes:
|
||||||
|
if subscope not in scopes:
|
||||||
|
if subscope in scope_definitions:
|
||||||
|
raise ValueError(
|
||||||
|
f"non-custom subscope {subscope} in {scope}={scope_definition} is not allowed."
|
||||||
|
f" Custom scopes may only have custom subscopes."
|
||||||
|
f" Roles should be used to assign multiple scopes together."
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
f"subscope {subscope} in {scope}={scope_definition} not found. All scopes must be defined."
|
||||||
|
)
|
||||||
|
|
||||||
|
extra_keys = set(scope_definition.keys()).difference(
|
||||||
|
["description", "subscopes"]
|
||||||
|
)
|
||||||
|
if extra_keys:
|
||||||
|
warnings.warn(
|
||||||
|
f"Ignoring unrecognized key(s) {', '.join(extra_keys)!r} in {scope}={scope_definition}",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
app_log.info(f"Defining custom scope {scope}")
|
||||||
|
# deferred evaluation for debug-logging
|
||||||
|
app_log.debug("Defining custom scope %s=%s", scope, scope_definition)
|
||||||
|
scope_definitions[scope] = scope_definition
|
||||||
|
@@ -501,11 +501,17 @@ class HubAuth(SingletonConfigurable):
|
|||||||
auth_header_name = 'Authorization'
|
auth_header_name = 'Authorization'
|
||||||
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
|
auth_header_pat = re.compile(r'(?:token|bearer)\s+(.+)', re.IGNORECASE)
|
||||||
|
|
||||||
def get_token(self, handler):
|
def get_token(self, handler, in_cookie=True):
|
||||||
"""Get the user token from a request
|
"""Get the token authenticating a request
|
||||||
|
|
||||||
|
.. versionchanged:: 2.2
|
||||||
|
in_cookie added.
|
||||||
|
Previously, only URL params and header were considered.
|
||||||
|
Pass `in_cookie=False` to preserve that behavior.
|
||||||
|
|
||||||
- in URL parameters: ?token=<token>
|
- in URL parameters: ?token=<token>
|
||||||
- in header: Authorization: token <token>
|
- in header: Authorization: token <token>
|
||||||
|
- in cookie (stored after oauth), if in_cookie is True
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user_token = handler.get_argument('token', '')
|
user_token = handler.get_argument('token', '')
|
||||||
@@ -516,8 +522,14 @@ class HubAuth(SingletonConfigurable):
|
|||||||
)
|
)
|
||||||
if m:
|
if m:
|
||||||
user_token = m.group(1)
|
user_token = m.group(1)
|
||||||
|
if not user_token and in_cookie:
|
||||||
|
user_token = self._get_token_cookie(handler)
|
||||||
return user_token
|
return user_token
|
||||||
|
|
||||||
|
def _get_token_cookie(self, handler):
|
||||||
|
"""Base class doesn't store tokens in cookies"""
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_user_cookie(self, handler):
|
def _get_user_cookie(self, handler):
|
||||||
"""Get the user model from a cookie"""
|
"""Get the user model from a cookie"""
|
||||||
# overridden in HubOAuth to store the access token after oauth
|
# overridden in HubOAuth to store the access token after oauth
|
||||||
@@ -553,8 +565,10 @@ class HubAuth(SingletonConfigurable):
|
|||||||
handler._cached_hub_user = user_model = None
|
handler._cached_hub_user = user_model = None
|
||||||
session_id = self.get_session_id(handler)
|
session_id = self.get_session_id(handler)
|
||||||
|
|
||||||
# check token first
|
# check token first, ignoring cookies
|
||||||
token = self.get_token(handler)
|
# because some checks are different when a request
|
||||||
|
# is token-authenticated (CORS-related)
|
||||||
|
token = self.get_token(handler, in_cookie=False)
|
||||||
if token:
|
if token:
|
||||||
user_model = self.user_for_token(token, session_id=session_id)
|
user_model = self.user_for_token(token, session_id=session_id)
|
||||||
if user_model:
|
if user_model:
|
||||||
@@ -614,11 +628,18 @@ class HubOAuth(HubAuth):
|
|||||||
"""
|
"""
|
||||||
return self.cookie_name + '-oauth-state'
|
return self.cookie_name + '-oauth-state'
|
||||||
|
|
||||||
def _get_user_cookie(self, handler):
|
def _get_token_cookie(self, handler):
|
||||||
|
"""Base class doesn't store tokens in cookies"""
|
||||||
token = handler.get_secure_cookie(self.cookie_name)
|
token = handler.get_secure_cookie(self.cookie_name)
|
||||||
|
if token:
|
||||||
|
# decode cookie bytes
|
||||||
|
token = token.decode('ascii', 'replace')
|
||||||
|
return token
|
||||||
|
|
||||||
|
def _get_user_cookie(self, handler):
|
||||||
|
token = self._get_token_cookie(handler)
|
||||||
session_id = self.get_session_id(handler)
|
session_id = self.get_session_id(handler)
|
||||||
if token:
|
if token:
|
||||||
token = token.decode('ascii', 'replace')
|
|
||||||
user_model = self.user_for_token(token, session_id=session_id)
|
user_model = self.user_for_token(token, session_id=session_id)
|
||||||
if user_model is None:
|
if user_model is None:
|
||||||
app_log.warning("Token stored in cookie may have expired")
|
app_log.warning("Token stored in cookie may have expired")
|
||||||
|
@@ -29,9 +29,9 @@ else:
|
|||||||
try:
|
try:
|
||||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
continue
|
|
||||||
if _import_error is None:
|
if _import_error is None:
|
||||||
_import_error = e
|
_import_error = e
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
if App is None:
|
if App is None:
|
||||||
|
@@ -16,7 +16,6 @@ import random
|
|||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import datetime
|
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
@@ -680,6 +679,7 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
s['hub_prefix'] = self.hub_prefix
|
s['hub_prefix'] = self.hub_prefix
|
||||||
s['hub_host'] = self.hub_host
|
s['hub_host'] = self.hub_host
|
||||||
s['hub_auth'] = self.hub_auth
|
s['hub_auth'] = self.hub_auth
|
||||||
|
s['page_config_hook'] = self.page_config_hook
|
||||||
csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
|
csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
|
||||||
self.hub_prefix, 'security/csp-report'
|
self.hub_prefix, 'security/csp-report'
|
||||||
)
|
)
|
||||||
@@ -707,6 +707,18 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
self.patch_default_headers()
|
self.patch_default_headers()
|
||||||
self.patch_templates()
|
self.patch_templates()
|
||||||
|
|
||||||
|
def page_config_hook(self, handler, page_config):
|
||||||
|
"""JupyterLab page config hook
|
||||||
|
|
||||||
|
Adds JupyterHub info to page config.
|
||||||
|
|
||||||
|
Places the JupyterHub API token in PageConfig.token.
|
||||||
|
|
||||||
|
Only has effect on jupyterlab_server >=2.9
|
||||||
|
"""
|
||||||
|
page_config["token"] = self.hub_auth.get_token(handler) or ""
|
||||||
|
return page_config
|
||||||
|
|
||||||
def patch_default_headers(self):
|
def patch_default_headers(self):
|
||||||
if hasattr(RequestHandler, '_orig_set_default_headers'):
|
if hasattr(RequestHandler, '_orig_set_default_headers'):
|
||||||
return
|
return
|
||||||
|
@@ -184,17 +184,38 @@ class Spawner(LoggingConfigurable):
|
|||||||
def last_activity(self):
|
def last_activity(self):
|
||||||
return self.orm_spawner.last_activity
|
return self.orm_spawner.last_activity
|
||||||
|
|
||||||
|
# Spawner.server is a wrapper of the ORM orm_spawner.server
|
||||||
|
# make sure it's always in sync with the underlying state
|
||||||
|
# this is harder to do with traitlets,
|
||||||
|
# which do not run on every access, only on set and first-get
|
||||||
|
_server = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def server(self):
|
def server(self):
|
||||||
if hasattr(self, '_server'):
|
# always check that we're in sync with orm_spawner
|
||||||
|
if not self.orm_spawner:
|
||||||
|
# no ORM spawner, nothing to check
|
||||||
|
return self._server
|
||||||
|
|
||||||
|
orm_server = self.orm_spawner.server
|
||||||
|
|
||||||
|
if orm_server is not None and (
|
||||||
|
self._server is None or orm_server is not self._server.orm_server
|
||||||
|
):
|
||||||
|
# self._server is not connected to orm_spawner
|
||||||
|
self._server = Server(orm_server=self.orm_spawner.server)
|
||||||
|
elif orm_server is None:
|
||||||
|
# no ORM server, clear it
|
||||||
|
self._server = None
|
||||||
return self._server
|
return self._server
|
||||||
if self.orm_spawner and self.orm_spawner.server:
|
|
||||||
return Server(orm_server=self.orm_spawner.server)
|
|
||||||
|
|
||||||
@server.setter
|
@server.setter
|
||||||
def server(self, server):
|
def server(self, server):
|
||||||
self._server = server
|
self._server = server
|
||||||
if self.orm_spawner:
|
if self.orm_spawner is not None:
|
||||||
|
if server is not None and server.orm_server == self.orm_spawner.server:
|
||||||
|
# no change
|
||||||
|
return
|
||||||
if self.orm_spawner.server is not None:
|
if self.orm_spawner.server is not None:
|
||||||
# delete the old value
|
# delete the old value
|
||||||
db = inspect(self.orm_spawner.server).session
|
db = inspect(self.orm_spawner.server).session
|
||||||
@@ -202,7 +223,13 @@ class Spawner(LoggingConfigurable):
|
|||||||
if server is None:
|
if server is None:
|
||||||
self.orm_spawner.server = None
|
self.orm_spawner.server = None
|
||||||
else:
|
else:
|
||||||
|
if server.orm_server is None:
|
||||||
|
self.log.warning(f"No ORM server for {self._log_name}")
|
||||||
self.orm_spawner.server = server.orm_server
|
self.orm_spawner.server = server.orm_server
|
||||||
|
elif server is not None:
|
||||||
|
self.log.warning(
|
||||||
|
"Setting Spawner.server for {self._log_name} with no underlying orm_spawner"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
|
@@ -26,6 +26,7 @@ Fixtures to add functionality or spawning behavior
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
@@ -44,6 +45,7 @@ import jupyterhub.services.service
|
|||||||
from . import mocking
|
from . import mocking
|
||||||
from .. import crypto
|
from .. import crypto
|
||||||
from .. import orm
|
from .. import orm
|
||||||
|
from .. import scopes
|
||||||
from ..roles import create_role
|
from ..roles import create_role
|
||||||
from ..roles import get_default_roles
|
from ..roles import get_default_roles
|
||||||
from ..roles import mock_roles
|
from ..roles import mock_roles
|
||||||
@@ -456,3 +458,11 @@ def create_service_with_scopes(app, create_temp_role):
|
|||||||
for service in temp_service:
|
for service in temp_service:
|
||||||
app.db.delete(service)
|
app.db.delete(service)
|
||||||
app.db.commit()
|
app.db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
def preserve_scopes():
|
||||||
|
"""Revert any custom scopes after test"""
|
||||||
|
scope_definitions = copy.deepcopy(scopes.scope_definitions)
|
||||||
|
yield scope_definitions
|
||||||
|
scopes.scope_definitions = scope_definitions
|
||||||
|
@@ -23,6 +23,7 @@ from tornado import httpserver
|
|||||||
from tornado import ioloop
|
from tornado import ioloop
|
||||||
from tornado import log
|
from tornado import log
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubAuthenticated
|
from jupyterhub.services.auth import HubAuthenticated
|
||||||
from jupyterhub.services.auth import HubOAuthCallbackHandler
|
from jupyterhub.services.auth import HubOAuthCallbackHandler
|
||||||
@@ -76,6 +77,13 @@ class OWhoAmIHandler(HubOAuthenticated, web.RequestHandler):
|
|||||||
Uses OAuth login flow
|
Uses OAuth login flow
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def get_login_url(self):
|
||||||
|
login_url = super().get_login_url()
|
||||||
|
scopes = self.get_argument("request-scope", None)
|
||||||
|
if scopes is not None:
|
||||||
|
login_url = url_concat(login_url, {"scope": scopes})
|
||||||
|
return login_url
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
def get(self):
|
def get(self):
|
||||||
self.write(self.get_current_user())
|
self.write(self.get_current_user())
|
||||||
|
@@ -471,6 +471,42 @@ async def test_get_users_state_filter(app, state):
|
|||||||
assert usernames == expected
|
assert usernames == expected
|
||||||
|
|
||||||
|
|
||||||
|
@mark.user
|
||||||
|
async def test_get_users_name_filter(app):
|
||||||
|
db = app.db
|
||||||
|
|
||||||
|
add_user(db, app=app, name='q')
|
||||||
|
add_user(db, app=app, name='qr')
|
||||||
|
add_user(db, app=app, name='qrs')
|
||||||
|
add_user(db, app=app, name='qrst')
|
||||||
|
added_usernames = {'q', 'qr', 'qrs', 'qrst'}
|
||||||
|
|
||||||
|
r = await api_request(app, 'users')
|
||||||
|
assert r.status_code == 200
|
||||||
|
response_users = [u.get("name") for u in r.json()]
|
||||||
|
assert added_usernames.intersection(response_users) == added_usernames
|
||||||
|
|
||||||
|
r = await api_request(app, 'users?name_filter=q')
|
||||||
|
assert r.status_code == 200
|
||||||
|
response_users = [u.get("name") for u in r.json()]
|
||||||
|
assert response_users == ['q', 'qr', 'qrs', 'qrst']
|
||||||
|
|
||||||
|
r = await api_request(app, 'users?name_filter=qr')
|
||||||
|
assert r.status_code == 200
|
||||||
|
response_users = [u.get("name") for u in r.json()]
|
||||||
|
assert response_users == ['qr', 'qrs', 'qrst']
|
||||||
|
|
||||||
|
r = await api_request(app, 'users?name_filter=qrs')
|
||||||
|
assert r.status_code == 200
|
||||||
|
response_users = [u.get("name") for u in r.json()]
|
||||||
|
assert response_users == ['qrs', 'qrst']
|
||||||
|
|
||||||
|
r = await api_request(app, 'users?name_filter=qrst')
|
||||||
|
assert r.status_code == 200
|
||||||
|
response_users = [u.get("name") for u in r.json()]
|
||||||
|
assert response_users == ['qrst']
|
||||||
|
|
||||||
|
|
||||||
@mark.user
|
@mark.user
|
||||||
async def test_get_self(app):
|
async def test_get_self(app):
|
||||||
db = app.db
|
db = app.db
|
||||||
@@ -1030,7 +1066,7 @@ async def test_never_spawn(app, no_patience, never_spawn):
|
|||||||
assert not app_user.spawner._spawn_pending
|
assert not app_user.spawner._spawn_pending
|
||||||
status = await app_user.spawner.poll()
|
status = await app_user.spawner.poll()
|
||||||
assert status is not None
|
assert status is not None
|
||||||
# failed spawn should decrements pending count
|
# failed spawn should decrement pending count
|
||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -1039,9 +1075,16 @@ async def test_bad_spawn(app, bad_spawn):
|
|||||||
name = 'prim'
|
name = 'prim'
|
||||||
user = add_user(db, app=app, name=name)
|
user = add_user(db, app=app, name=name)
|
||||||
r = await api_request(app, 'users', name, 'server', method='post')
|
r = await api_request(app, 'users', name, 'server', method='post')
|
||||||
|
# check that we don't re-use spawners that failed
|
||||||
|
user.spawners[''].reused = True
|
||||||
assert r.status_code == 500
|
assert r.status_code == 500
|
||||||
assert app.users.count_active_users()['pending'] == 0
|
assert app.users.count_active_users()['pending'] == 0
|
||||||
|
|
||||||
|
r = await api_request(app, 'users', name, 'server', method='post')
|
||||||
|
# check that we don't re-use spawners that failed
|
||||||
|
spawner = user.spawners['']
|
||||||
|
assert not getattr(spawner, 'reused', False)
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_nosuch_user(app):
|
async def test_spawn_nosuch_user(app):
|
||||||
r = await api_request(app, 'users', "nosuchuser", 'server', method='post')
|
r = await api_request(app, 'users', "nosuchuser", 'server', method='post')
|
||||||
|
@@ -6,7 +6,6 @@ import os
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from distutils.version import LooseVersion as V
|
|
||||||
from subprocess import check_output
|
from subprocess import check_output
|
||||||
from subprocess import PIPE
|
from subprocess import PIPE
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
@@ -33,7 +32,7 @@ def test_help_all():
|
|||||||
assert '--JupyterHub.ip' in out
|
assert '--JupyterHub.ip' in out
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(V(traitlets.__version__) < V('5'), reason="requires traitlets 5")
|
@pytest.mark.skipif(traitlets.version_info < (5,), reason="requires traitlets 5")
|
||||||
def test_show_config(tmpdir):
|
def test_show_config(tmpdir):
|
||||||
tmpdir.chdir()
|
tmpdir.chdir()
|
||||||
p = Popen(
|
p = Popen(
|
||||||
|
@@ -128,11 +128,20 @@ async def test_admin_sort(app, sort):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_redirect(app):
|
@pytest.mark.parametrize("last_failed", [True, False])
|
||||||
|
async def test_spawn_redirect(app, last_failed):
|
||||||
name = 'wash'
|
name = 'wash'
|
||||||
cookies = await app.login_user(name)
|
cookies = await app.login_user(name)
|
||||||
u = app.users[orm.User.find(app.db, name)]
|
u = app.users[orm.User.find(app.db, name)]
|
||||||
|
|
||||||
|
if last_failed:
|
||||||
|
# mock a failed spawn
|
||||||
|
last_spawner = u.spawners['']
|
||||||
|
last_spawner._spawn_future = asyncio.Future()
|
||||||
|
last_spawner._spawn_future.set_exception(RuntimeError("I failed!"))
|
||||||
|
else:
|
||||||
|
last_spawner = None
|
||||||
|
|
||||||
status = await u.spawner.poll()
|
status = await u.spawner.poll()
|
||||||
assert status is not None
|
assert status is not None
|
||||||
|
|
||||||
@@ -141,6 +150,10 @@ async def test_spawn_redirect(app):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
print(urlparse(r.url))
|
print(urlparse(r.url))
|
||||||
path = urlparse(r.url).path
|
path = urlparse(r.url).path
|
||||||
|
|
||||||
|
# ensure we got a new spawner
|
||||||
|
assert u.spawners[''] is not last_spawner
|
||||||
|
|
||||||
# make sure we visited hub/spawn-pending after spawn
|
# make sure we visited hub/spawn-pending after spawn
|
||||||
# if spawn was really quick, we might get redirected all the way to the running server,
|
# if spawn was really quick, we might get redirected all the way to the running server,
|
||||||
# so check history instead of r.url
|
# so check history instead of r.url
|
||||||
@@ -258,6 +271,25 @@ async def test_spawn_page(app):
|
|||||||
assert FormSpawner.options_form in r.text
|
assert FormSpawner.options_form in r.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_spawn_page_after_failed(app, user):
|
||||||
|
cookies = await app.login_user(user.name)
|
||||||
|
|
||||||
|
# mock a failed spawn
|
||||||
|
last_spawner = user.spawners['']
|
||||||
|
last_spawner._spawn_future = asyncio.Future()
|
||||||
|
last_spawner._spawn_future.set_exception(RuntimeError("I failed!"))
|
||||||
|
|
||||||
|
with mock.patch.dict(app.users.settings, {'spawner_class': FormSpawner}):
|
||||||
|
r = await get_page('spawn', app, cookies=cookies)
|
||||||
|
spawner = user.spawners['']
|
||||||
|
# make sure we didn't reuse last spawner
|
||||||
|
assert isinstance(spawner, FormSpawner)
|
||||||
|
assert spawner is not last_spawner
|
||||||
|
assert r.url.endswith('/spawn')
|
||||||
|
spawner = user.spawners['']
|
||||||
|
assert FormSpawner.options_form in r.text
|
||||||
|
|
||||||
|
|
||||||
async def test_spawn_page_falsy_callable(app):
|
async def test_spawn_page_falsy_callable(app):
|
||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
app.users.settings, {'spawner_class': FalsyCallableFormSpawner}
|
app.users.settings, {'spawner_class': FalsyCallableFormSpawner}
|
||||||
@@ -1102,6 +1134,7 @@ async def test_oauth_page_scope_appearance(
|
|||||||
)
|
)
|
||||||
service = mockservice_url
|
service = mockservice_url
|
||||||
user = create_user_with_scopes("access:services")
|
user = create_user_with_scopes("access:services")
|
||||||
|
roles.grant_role(app.db, user, service_role)
|
||||||
oauth_client = (
|
oauth_client = (
|
||||||
app.db.query(orm.OAuthClient)
|
app.db.query(orm.OAuthClient)
|
||||||
.filter_by(identifier=service.oauth_client_id)
|
.filter_by(identifier=service.oauth_client_id)
|
||||||
|
@@ -498,7 +498,7 @@ async def test_load_roles_users(tmpdir, request, explicit_allowed_users):
|
|||||||
|
|
||||||
|
|
||||||
@mark.role
|
@mark.role
|
||||||
async def test_load_roles_services(tmpdir, request):
|
async def test_load_roles_services(tmpdir, request, preserve_scopes):
|
||||||
services = [
|
services = [
|
||||||
{'name': 'idle-culler', 'api_token': 'some-token'},
|
{'name': 'idle-culler', 'api_token': 'some-token'},
|
||||||
{'name': 'user_service', 'api_token': 'some-other-token'},
|
{'name': 'user_service', 'api_token': 'some-other-token'},
|
||||||
@@ -509,6 +509,11 @@ async def test_load_roles_services(tmpdir, request):
|
|||||||
'some-other-token': 'user_service',
|
'some-other-token': 'user_service',
|
||||||
'secret-token': 'admin_service',
|
'secret-token': 'admin_service',
|
||||||
}
|
}
|
||||||
|
custom_scopes = {
|
||||||
|
"custom:empty-scope": {
|
||||||
|
"description": "empty custom scope",
|
||||||
|
}
|
||||||
|
}
|
||||||
roles_to_load = [
|
roles_to_load = [
|
||||||
{
|
{
|
||||||
'name': 'idle-culler',
|
'name': 'idle-culler',
|
||||||
@@ -518,11 +523,13 @@ async def test_load_roles_services(tmpdir, request):
|
|||||||
'read:users:activity',
|
'read:users:activity',
|
||||||
'read:servers',
|
'read:servers',
|
||||||
'servers',
|
'servers',
|
||||||
|
'custom:empty-scope',
|
||||||
],
|
],
|
||||||
'services': ['idle-culler'],
|
'services': ['idle-culler'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
kwargs = {
|
kwargs = {
|
||||||
|
'custom_scopes': custom_scopes,
|
||||||
'load_roles': roles_to_load,
|
'load_roles': roles_to_load,
|
||||||
'services': services,
|
'services': services,
|
||||||
'service_tokens': service_tokens,
|
'service_tokens': service_tokens,
|
||||||
|
@@ -9,6 +9,7 @@ from tornado.httputil import HTTPServerRequest
|
|||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import roles
|
from .. import roles
|
||||||
|
from .. import scopes
|
||||||
from ..handlers import BaseHandler
|
from ..handlers import BaseHandler
|
||||||
from ..scopes import _check_scope_access
|
from ..scopes import _check_scope_access
|
||||||
from ..scopes import _intersect_expanded_scopes
|
from ..scopes import _intersect_expanded_scopes
|
||||||
@@ -1048,3 +1049,82 @@ async def test_list_groups_filter(
|
|||||||
for name in sorted(expected)
|
for name in sorted(expected)
|
||||||
]
|
]
|
||||||
assert sorted(r.json(), key=itemgetter('name')) == expected_models
|
assert sorted(r.json(), key=itemgetter('name')) == expected_models
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"custom_scopes",
|
||||||
|
[
|
||||||
|
{"custom:okay": {"description": "simple custom scope"}},
|
||||||
|
{
|
||||||
|
"custom:parent": {
|
||||||
|
"description": "parent",
|
||||||
|
"subscopes": ["custom:child"],
|
||||||
|
},
|
||||||
|
"custom:child": {"description": "child"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"custom:extra": {
|
||||||
|
"description": "I have extra info",
|
||||||
|
"extra": "warn about me",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_custom_scopes(preserve_scopes, custom_scopes):
|
||||||
|
scopes.define_custom_scopes(custom_scopes)
|
||||||
|
for name, scope_def in custom_scopes.items():
|
||||||
|
assert name in scopes.scope_definitions
|
||||||
|
assert scopes.scope_definitions[name] == scope_def
|
||||||
|
|
||||||
|
# make sure describe works after registering custom scopes
|
||||||
|
scopes.describe_raw_scopes(list(custom_scopes.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"custom_scopes",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"read:users": {
|
||||||
|
"description": "Can't override",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"custom:empty": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"notcustom:prefix": {"descroption": "bad prefix"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"custom:!illegal": {"descroption": "bad character"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"custom:badsubscope": {
|
||||||
|
"description": "non-custom subscope not allowed",
|
||||||
|
"subscopes": [
|
||||||
|
"read:users",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"custom:nosubscope": {
|
||||||
|
"description": "subscope not defined",
|
||||||
|
"subscopes": [
|
||||||
|
"custom:undefined",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"custom:badsubscope": {
|
||||||
|
"description": "subscope not a list",
|
||||||
|
"subscopes": "custom:notalist",
|
||||||
|
},
|
||||||
|
"custom:notalist": {
|
||||||
|
"description": "the subscope",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_custom_scopes_bad(preserve_scopes, custom_scopes):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
scopes.define_custom_scopes(custom_scopes)
|
||||||
|
assert scopes.scope_definitions == preserve_scopes
|
||||||
|
@@ -5,18 +5,20 @@ import sys
|
|||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
from urllib.parse import quote
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
from pytest import raises
|
from pytest import raises
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import roles
|
from .. import roles
|
||||||
|
from .. import scopes
|
||||||
from ..services.auth import _ExpiringDict
|
from ..services.auth import _ExpiringDict
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .mocking import public_url
|
from .mocking import public_url
|
||||||
from .test_api import add_user
|
|
||||||
from .utils import async_requests
|
from .utils import async_requests
|
||||||
from .utils import AsyncSession
|
from .utils import AsyncSession
|
||||||
|
|
||||||
@@ -226,6 +228,10 @@ async def test_hubauth_service_token(request, app, mockservice_url, scopes, allo
|
|||||||
# requesting subset
|
# requesting subset
|
||||||
(["admin", "user"], ["user"], ["user"]),
|
(["admin", "user"], ["user"], ["user"]),
|
||||||
(["user", "token", "server"], ["token", "user"], ["token", "user"]),
|
(["user", "token", "server"], ["token", "user"], ["token", "user"]),
|
||||||
|
(["admin", "user", "read-only"], ["read-only"], ["read-only"]),
|
||||||
|
# requesting valid subset, some not held by user
|
||||||
|
(["admin", "user"], ["admin", "user"], ["user"]),
|
||||||
|
(["admin", "user"], ["admin"], []),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_oauth_service_roles(
|
async def test_oauth_service_roles(
|
||||||
@@ -235,6 +241,7 @@ async def test_oauth_service_roles(
|
|||||||
client_allowed_roles,
|
client_allowed_roles,
|
||||||
request_roles,
|
request_roles,
|
||||||
expected_roles,
|
expected_roles,
|
||||||
|
preserve_scopes,
|
||||||
):
|
):
|
||||||
service = mockservice_url
|
service = mockservice_url
|
||||||
oauth_client = (
|
oauth_client = (
|
||||||
@@ -242,30 +249,40 @@ async def test_oauth_service_roles(
|
|||||||
.filter_by(identifier=service.oauth_client_id)
|
.filter_by(identifier=service.oauth_client_id)
|
||||||
.one()
|
.one()
|
||||||
)
|
)
|
||||||
|
scopes.define_custom_scopes(
|
||||||
|
{
|
||||||
|
"custom:jupyter_server:read:*": {
|
||||||
|
"description": "read-only access to jupyter server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
roles.create_role(
|
||||||
|
app.db,
|
||||||
|
{
|
||||||
|
"name": "read-only",
|
||||||
|
"description": "read-only access to servers",
|
||||||
|
"scopes": [
|
||||||
|
"access:servers",
|
||||||
|
"custom:jupyter_server:read:*",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
oauth_client.allowed_roles = [
|
oauth_client.allowed_roles = [
|
||||||
orm.Role.find(app.db, role_name) for role_name in client_allowed_roles
|
orm.Role.find(app.db, role_name) for role_name in client_allowed_roles
|
||||||
]
|
]
|
||||||
app.db.commit()
|
app.db.commit()
|
||||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||||
|
if request_roles:
|
||||||
|
url = url_concat(url, {"request-scope": " ".join(request_roles)})
|
||||||
# first request is only going to login and get us to the oauth form page
|
# first request is only going to login and get us to the oauth form page
|
||||||
s = AsyncSession()
|
s = AsyncSession()
|
||||||
user = create_user_with_scopes("access:services")
|
user = create_user_with_scopes("access:services")
|
||||||
roles.grant_role(app.db, user, "user")
|
roles.grant_role(app.db, user, "user")
|
||||||
|
roles.grant_role(app.db, user, "read-only")
|
||||||
name = user.name
|
name = user.name
|
||||||
s.cookies = await app.login_user(name)
|
s.cookies = await app.login_user(name)
|
||||||
|
|
||||||
r = await s.get(url)
|
r = await s.get(url)
|
||||||
r.raise_for_status()
|
|
||||||
# we should be looking at the oauth confirmation page
|
|
||||||
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
|
|
||||||
# verify oauth state cookie was set at some point
|
|
||||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
|
||||||
|
|
||||||
# submit the oauth form to complete authorization
|
|
||||||
data = {}
|
|
||||||
if request_roles:
|
|
||||||
data["scopes"] = request_roles
|
|
||||||
r = await s.post(r.url, data=data, headers={'Referer': r.url})
|
|
||||||
if expected_roles is None:
|
if expected_roles is None:
|
||||||
# expected failed auth, stop here
|
# expected failed auth, stop here
|
||||||
# verify expected 'invalid scope' error, not server error
|
# verify expected 'invalid scope' error, not server error
|
||||||
@@ -275,6 +292,21 @@ async def test_oauth_service_roles(
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
return
|
return
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
# we should be looking at the oauth confirmation page
|
||||||
|
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
|
||||||
|
# verify oauth state cookie was set at some point
|
||||||
|
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||||
|
|
||||||
|
page = BeautifulSoup(r.text, "html.parser")
|
||||||
|
scope_inputs = page.find_all("input", {"name": "scopes"})
|
||||||
|
scope_values = [input["value"] for input in scope_inputs]
|
||||||
|
print("Submitting request with scope values", scope_values)
|
||||||
|
# submit the oauth form to complete authorization
|
||||||
|
data = {}
|
||||||
|
if scope_values:
|
||||||
|
data["scopes"] = scope_values
|
||||||
|
r = await s.post(r.url, data=data, headers={'Referer': r.url})
|
||||||
|
r.raise_for_status()
|
||||||
assert r.url == url
|
assert r.url == url
|
||||||
# verify oauth cookie is set
|
# verify oauth cookie is set
|
||||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||||
@@ -374,7 +406,11 @@ async def test_oauth_access_scopes(
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"token_roles, hits_page",
|
"token_roles, hits_page",
|
||||||
[([], True), (['writer'], True), (['writer', 'reader'], False)],
|
[
|
||||||
|
([], True),
|
||||||
|
(['writer'], True),
|
||||||
|
(['writer', 'reader'], False),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
async def test_oauth_page_hit(
|
async def test_oauth_page_hit(
|
||||||
app,
|
app,
|
||||||
@@ -390,6 +426,8 @@ async def test_oauth_page_hit(
|
|||||||
}
|
}
|
||||||
service = mockservice_url
|
service = mockservice_url
|
||||||
user = create_user_with_scopes("access:services", "self")
|
user = create_user_with_scopes("access:services", "self")
|
||||||
|
for role in test_roles.values():
|
||||||
|
roles.grant_role(app.db, user, role)
|
||||||
user.new_api_token()
|
user.new_api_token()
|
||||||
token = user.api_tokens[0]
|
token = user.api_tokens[0]
|
||||||
token.roles = [test_roles[t] for t in token_roles]
|
token.roles = [test_roles[t] for t in token_roles]
|
||||||
|
@@ -483,3 +483,56 @@ async def test_spawner_options_from_form_with_spawner(db):
|
|||||||
for key, value in form_data.items():
|
for key, value in form_data.items():
|
||||||
assert key in result
|
assert key in result
|
||||||
assert result[key] == value
|
assert result[key] == value
|
||||||
|
|
||||||
|
|
||||||
|
def test_spawner_server(db):
|
||||||
|
spawner = new_spawner(db)
|
||||||
|
spawner.orm_spawner = None
|
||||||
|
orm_spawner = orm.Spawner()
|
||||||
|
orm_server = orm.Server(base_url="/1/")
|
||||||
|
orm_spawner.server = orm_server
|
||||||
|
db.add(orm_spawner)
|
||||||
|
db.add(orm_server)
|
||||||
|
db.commit()
|
||||||
|
# initial: no orm_spawner
|
||||||
|
assert spawner.server is None
|
||||||
|
# assigning spawner.orm_spawner updates spawner.server
|
||||||
|
spawner.orm_spawner = orm_spawner
|
||||||
|
assert spawner.server is not None
|
||||||
|
assert spawner.server.orm_server is orm_server
|
||||||
|
# update orm_spawner.server without direct access on Spawner
|
||||||
|
orm_spawner.server = new_server = orm.Server(base_url="/2/")
|
||||||
|
db.commit()
|
||||||
|
assert spawner.server is not None
|
||||||
|
assert spawner.server.orm_server is not orm_server
|
||||||
|
assert spawner.server.orm_server is new_server
|
||||||
|
# clear orm_server via orm_spawner clears spawner.server
|
||||||
|
orm_spawner.server = None
|
||||||
|
db.commit()
|
||||||
|
assert spawner.server is None
|
||||||
|
# assigning spawner.server updates orm_spawner.server
|
||||||
|
orm_server = orm.Server(base_url="/3/")
|
||||||
|
db.add(orm_server)
|
||||||
|
db.commit()
|
||||||
|
spawner.server = server = Server(orm_server=orm_server)
|
||||||
|
db.commit()
|
||||||
|
assert spawner.server is server
|
||||||
|
assert spawner.orm_spawner.server is orm_server
|
||||||
|
# change orm spawner.server
|
||||||
|
orm_server = orm.Server(base_url="/4/")
|
||||||
|
db.add(orm_server)
|
||||||
|
db.commit()
|
||||||
|
spawner.server = server2 = Server(orm_server=orm_server)
|
||||||
|
assert spawner.server is server2
|
||||||
|
assert spawner.orm_spawner.server is orm_server
|
||||||
|
# clear server via spawner.server
|
||||||
|
spawner.server = None
|
||||||
|
db.commit()
|
||||||
|
assert spawner.orm_spawner.server is None
|
||||||
|
|
||||||
|
# test with no underlying orm.Spawner
|
||||||
|
# (only relevant for mocking, never true for actual Spawners)
|
||||||
|
spawner = Spawner()
|
||||||
|
spawner.server = Server.from_url("http://1.2.3.4")
|
||||||
|
assert spawner.server is not None
|
||||||
|
assert spawner.server.ip == "1.2.3.4"
|
||||||
|
@@ -253,6 +253,22 @@ class User:
|
|||||||
def spawner_class(self):
|
def spawner_class(self):
|
||||||
return self.settings.get('spawner_class', LocalProcessSpawner)
|
return self.settings.get('spawner_class', LocalProcessSpawner)
|
||||||
|
|
||||||
|
def get_spawner(self, server_name="", replace_failed=False):
|
||||||
|
"""Get a spawner by name
|
||||||
|
|
||||||
|
replace_failed governs whether a failed spawner should be replaced
|
||||||
|
or returned (default: returned).
|
||||||
|
|
||||||
|
.. versionadded:: 2.2
|
||||||
|
"""
|
||||||
|
spawner = self.spawners[server_name]
|
||||||
|
if replace_failed and spawner._failed:
|
||||||
|
self.log.debug(f"Discarding failed spawner {spawner._log_name}")
|
||||||
|
# remove failed spawner, create a new one
|
||||||
|
self.spawners.pop(server_name)
|
||||||
|
spawner = self.spawners[server_name]
|
||||||
|
return spawner
|
||||||
|
|
||||||
def sync_groups(self, group_names):
|
def sync_groups(self, group_names):
|
||||||
"""Synchronize groups with database"""
|
"""Synchronize groups with database"""
|
||||||
|
|
||||||
@@ -628,7 +644,7 @@ class User:
|
|||||||
api_token = self.new_api_token(note=note, roles=['server'])
|
api_token = self.new_api_token(note=note, roles=['server'])
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
spawner = self.spawners[server_name]
|
spawner = self.get_spawner(server_name, replace_failed=True)
|
||||||
spawner.server = server = Server(orm_server=orm_server)
|
spawner.server = server = Server(orm_server=orm_server)
|
||||||
assert spawner.orm_spawner.server is orm_server
|
assert spawner.orm_spawner.server is orm_server
|
||||||
|
|
||||||
|
@@ -11,7 +11,7 @@ target_version = [
|
|||||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||||
|
|
||||||
[tool.tbump.version]
|
[tool.tbump.version]
|
||||||
current = "2.2.0.dev"
|
current = "2.3.0.dev"
|
||||||
|
|
||||||
# Example of a semver regexp.
|
# Example of a semver regexp.
|
||||||
# Make sure this matches current_version before
|
# Make sure this matches current_version before
|
||||||
|
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user