mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-14 05:23:01 +00:00
resolved conflicts with rbac branch
This commit is contained in:
61
.github/workflows/release.yml
vendored
Normal file
61
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Build releases and (on tags) publish to PyPI
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
# always build releases (to make sure wheel-building works)
|
||||||
|
# but only publish to PyPI on tags
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-release:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.8
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: "14"
|
||||||
|
|
||||||
|
- name: install build package
|
||||||
|
run: |
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install build
|
||||||
|
pip freeze
|
||||||
|
|
||||||
|
- name: build release
|
||||||
|
run: |
|
||||||
|
python -m build --sdist --wheel .
|
||||||
|
ls -l dist
|
||||||
|
|
||||||
|
- name: verify wheel
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
pip install ./*.whl
|
||||||
|
# verify data-files are installed where they are found
|
||||||
|
cat <<EOF | python
|
||||||
|
import os
|
||||||
|
from jupyterhub._data import DATA_FILES_PATH
|
||||||
|
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
|
||||||
|
assert os.path.exists(DATA_FILES_PATH), DATA_FILES_PATH
|
||||||
|
for subpath in (
|
||||||
|
"templates/page.html",
|
||||||
|
"static/css/style.min.css",
|
||||||
|
"static/components/jquery/dist/jquery.js",
|
||||||
|
):
|
||||||
|
path = os.path.join(DATA_FILES_PATH, subpath)
|
||||||
|
assert os.path.exists(path), path
|
||||||
|
print("OK")
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Publish to PyPI
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
|
TWINE_USERNAME: __token__
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
pip install twine
|
||||||
|
twine upload --skip-existing dist/*
|
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
|
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
|
||||||
#
|
#
|
||||||
name: Run tests
|
name: Test
|
||||||
|
|
||||||
# Trigger the workflow's on all PRs but only on pushed tags or commits to
|
# Trigger the workflow's on all PRs but only on pushed tags or commits to
|
||||||
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
|
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
|
||||||
@@ -9,6 +9,7 @@ name: Run tests
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
@@ -13,7 +13,7 @@
|
|||||||
[](https://pypi.python.org/pypi/jupyterhub)
|
[](https://pypi.python.org/pypi/jupyterhub)
|
||||||
[](https://www.npmjs.com/package/jupyterhub)
|
[](https://www.npmjs.com/package/jupyterhub)
|
||||||
[](https://jupyterhub.readthedocs.org/en/latest/)
|
[](https://jupyterhub.readthedocs.org/en/latest/)
|
||||||
[](https://travis-ci.com/jupyterhub/jupyterhub)
|
[](https://github.com/jupyterhub/jupyterhub/actions)
|
||||||
[](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
|
[](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
|
||||||
[](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
|
[](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
|
||||||
[](https://codecov.io/gh/jupyterhub/jupyterhub)
|
[](https://codecov.io/gh/jupyterhub/jupyterhub)
|
||||||
|
@@ -7,6 +7,62 @@ command line for details.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 1.3
|
||||||
|
|
||||||
|
JupyterHub 1.3 is a small feature release. Highlights include:
|
||||||
|
|
||||||
|
- Require Python >=3.6 (jupyterhub 1.2 is the last release to support 3.5)
|
||||||
|
- Add a `?state=` filter for getting user list, allowing much quicker responses
|
||||||
|
when retrieving a small fraction of users.
|
||||||
|
`state` can be `active`, `inactive`, or `ready`.
|
||||||
|
- prometheus metrics now include a `jupyterhub_` prefix,
|
||||||
|
so deployments may need to update their grafana charts to match.
|
||||||
|
- page templates can now be [async](https://jinja.palletsprojects.com/en/2.11.x/api/#async-support)!
|
||||||
|
|
||||||
|
### [1.3.0]
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/1.2.1...1.3.0))
|
||||||
|
|
||||||
|
#### Enhancements made
|
||||||
|
|
||||||
|
* allow services to call /api/user to identify themselves [#3293](https://github.com/jupyterhub/jupyterhub/pull/3293) ([@minrk](https://github.com/minrk))
|
||||||
|
* Add optional user agreement to login screen [#3264](https://github.com/jupyterhub/jupyterhub/pull/3264) ([@tlvu](https://github.com/tlvu))
|
||||||
|
* [Metrics] Add prefix to prometheus metrics to group all jupyterhub metrics [#3243](https://github.com/jupyterhub/jupyterhub/pull/3243) ([@agp8x](https://github.com/agp8x))
|
||||||
|
* Allow options_from_form to be configurable [#3225](https://github.com/jupyterhub/jupyterhub/pull/3225) ([@cbanek](https://github.com/cbanek))
|
||||||
|
* add ?state= filter for GET /users [#3177](https://github.com/jupyterhub/jupyterhub/pull/3177) ([@minrk](https://github.com/minrk))
|
||||||
|
* Enable async support in jinja2 templates [#3176](https://github.com/jupyterhub/jupyterhub/pull/3176) ([@yuvipanda](https://github.com/yuvipanda))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
* fix increasing pagination limits [#3294](https://github.com/jupyterhub/jupyterhub/pull/3294) ([@minrk](https://github.com/minrk))
|
||||||
|
* fix and test TOTAL_USERS count [#3289](https://github.com/jupyterhub/jupyterhub/pull/3289) ([@minrk](https://github.com/minrk))
|
||||||
|
* Fix asyncio deprecation asyncio.Task.all_tasks [#3298](https://github.com/jupyterhub/jupyterhub/pull/3298) ([@coffeebenzene](https://github.com/coffeebenzene))
|
||||||
|
|
||||||
|
#### Maintenance and upkeep improvements
|
||||||
|
|
||||||
|
* bump oldest-required prometheus-client [#3292](https://github.com/jupyterhub/jupyterhub/pull/3292) ([@minrk](https://github.com/minrk))
|
||||||
|
* bump black pre-commit hook to 20.8 [#3287](https://github.com/jupyterhub/jupyterhub/pull/3287) ([@minrk](https://github.com/minrk))
|
||||||
|
* Test internal_ssl separately [#3266](https://github.com/jupyterhub/jupyterhub/pull/3266) ([@0mar](https://github.com/0mar))
|
||||||
|
* wait for pending spawns in spawn_form_admin_access [#3253](https://github.com/jupyterhub/jupyterhub/pull/3253) ([@minrk](https://github.com/minrk))
|
||||||
|
* Assume py36 and remove @gen.coroutine etc. [#3242](https://github.com/jupyterhub/jupyterhub/pull/3242) ([@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Documentation improvements
|
||||||
|
|
||||||
|
* Fix curl in jupyter announcements [#3286](https://github.com/jupyterhub/jupyterhub/pull/3286) ([@Sangarshanan](https://github.com/Sangarshanan))
|
||||||
|
* CONTRIBUTING: Fix contributor guide URL [#3281](https://github.com/jupyterhub/jupyterhub/pull/3281) ([@olifre](https://github.com/olifre))
|
||||||
|
* Update services.md [#3267](https://github.com/jupyterhub/jupyterhub/pull/3267) ([@slemonide](https://github.com/slemonide))
|
||||||
|
* [Docs] Fix https reverse proxy redirect issues [#3244](https://github.com/jupyterhub/jupyterhub/pull/3244) ([@mhwasil](https://github.com/mhwasil))
|
||||||
|
* Fixed idle-culler references. [#3300](https://github.com/jupyterhub/jupyterhub/pull/3300) ([@mxjeff](https://github.com/mxjeff))
|
||||||
|
* Remove the extra parenthesis in service.md [#3303](https://github.com/jupyterhub/jupyterhub/pull/3303) ([@Sangarshanan](https://github.com/Sangarshanan))
|
||||||
|
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2020-10-30&to=2020-12-11&type=c))
|
||||||
|
|
||||||
|
[@0mar](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3A0mar+updated%3A2020-10-30..2020-12-11&type=Issues) | [@agp8x](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aagp8x+updated%3A2020-10-30..2020-12-11&type=Issues) | [@alexweav](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aalexweav+updated%3A2020-10-30..2020-12-11&type=Issues) | [@belfhi](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abelfhi+updated%3A2020-10-30..2020-12-11&type=Issues) | [@betatim](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Abetatim+updated%3A2020-10-30..2020-12-11&type=Issues) | [@cbanek](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acbanek+updated%3A2020-10-30..2020-12-11&type=Issues) | [@cmd-ntrf](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acmd-ntrf+updated%3A2020-10-30..2020-12-11&type=Issues) | [@coffeebenzene](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Acoffeebenzene+updated%3A2020-10-30..2020-12-11&type=Issues) | [@consideRatio](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2020-10-30..2020-12-11&type=Issues) | [@danlester](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adanlester+updated%3A2020-10-30..2020-12-11&type=Issues) | [@fcollonval](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Afcollonval+updated%3A2020-10-30..2020-12-11&type=Issues) | [@GeorgianaElena](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AGeorgianaElena+updated%3A2020-10-30..2020-12-11&type=Issues) | [@ianabc](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aianabc+updated%3A2020-10-30..2020-12-11&type=Issues) | [@IvanaH8](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AIvanaH8+updated%3A2020-10-30..2020-12-11&type=Issues) | [@manics](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2020-10-30..2020-12-11&type=Issues) | [@meeseeksmachine](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ameeseeksmachine+updated%3A2020-10-30..2020-12-11&type=Issues) | [@mhwasil](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amhwasil+updated%3A2020-10-30..2020-12-11&type=Issues) | [@minrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2020-10-30..2020-12-11&type=Issues) | [@mriedem](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amriedem+updated%3A2020-10-30..2020-12-11&type=Issues) | [@mxjeff](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amxjeff+updated%3A2020-10-30..2020-12-11&type=Issues) | [@olifre](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aolifre+updated%3A2020-10-30..2020-12-11&type=Issues) | [@rcthomas](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arcthomas+updated%3A2020-10-30..2020-12-11&type=Issues) | [@rgbkrk](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Argbkrk+updated%3A2020-10-30..2020-12-11&type=Issues) | [@rkdarst](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Arkdarst+updated%3A2020-10-30..2020-12-11&type=Issues) | [@Sangarshanan](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ASangarshanan+updated%3A2020-10-30..2020-12-11&type=Issues) | [@slemonide](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aslemonide+updated%3A2020-10-30..2020-12-11&type=Issues) | [@support](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asupport+updated%3A2020-10-30..2020-12-11&type=Issues) | [@tlvu](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atlvu+updated%3A2020-10-30..2020-12-11&type=Issues) | [@welcome](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Awelcome+updated%3A2020-10-30..2020-12-11&type=Issues) | [@yuvipanda](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2020-10-30..2020-12-11&type=Issues)
|
||||||
|
|
||||||
|
|
||||||
## 1.2
|
## 1.2
|
||||||
|
|
||||||
### [1.2.2] 2020-11-27
|
### [1.2.2] 2020-11-27
|
||||||
@@ -911,7 +967,9 @@ Fix removal of `/login` page in 0.4.0, breaking some OAuth providers.
|
|||||||
First preview release
|
First preview release
|
||||||
|
|
||||||
|
|
||||||
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/1.2.1...HEAD
|
[Unreleased]: https://github.com/jupyterhub/jupyterhub/compare/1.3.0...HEAD
|
||||||
|
[1.3.0]: https://github.com/jupyterhub/jupyterhub/compare/1.2.1...1.3.0
|
||||||
|
[1.2.2]: https://github.com/jupyterhub/jupyterhub/compare/1.2.1...1.2.2
|
||||||
[1.2.1]: https://github.com/jupyterhub/jupyterhub/compare/1.2.0...1.2.1
|
[1.2.1]: https://github.com/jupyterhub/jupyterhub/compare/1.2.0...1.2.1
|
||||||
[1.2.0]: https://github.com/jupyterhub/jupyterhub/compare/1.1.0...1.2.0
|
[1.2.0]: https://github.com/jupyterhub/jupyterhub/compare/1.1.0...1.2.0
|
||||||
[1.1.0]: https://github.com/jupyterhub/jupyterhub/compare/1.0.0...1.1.0
|
[1.1.0]: https://github.com/jupyterhub/jupyterhub/compare/1.0.0...1.1.0
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
Eventlogging and Telemetry
|
Eventlogging and Telemetry
|
||||||
==========================
|
==========================
|
||||||
|
|
||||||
JupyterHub can be configured to record structured events from a running server using Jupyter's `Telemetry System`_. The types of events that JupyterHub emits are defined by `JSON schemas`_ listed below_
|
JupyterHub can be configured to record structured events from a running server using Jupyter's `Telemetry System`_. The types of events that JupyterHub emits are defined by `JSON schemas`_ listed at the bottom of this page_.
|
||||||
|
|
||||||
emitted as JSON data, defined and validated by the JSON schemas listed below.
|
|
||||||
|
|
||||||
|
|
||||||
.. _logging: https://docs.python.org/3/library/logging.html
|
.. _logging: https://docs.python.org/3/library/logging.html
|
||||||
.. _`Telemetry System`: https://github.com/jupyter/telemetry
|
.. _`Telemetry System`: https://github.com/jupyter/telemetry
|
||||||
@@ -38,13 +35,12 @@ Here's a basic example:
|
|||||||
The output is a file, ``"event.log"``, with events recorded as JSON data.
|
The output is a file, ``"event.log"``, with events recorded as JSON data.
|
||||||
|
|
||||||
|
|
||||||
|
.. _page:
|
||||||
.. _below:
|
|
||||||
|
|
||||||
Event schemas
|
Event schemas
|
||||||
-------------
|
-------------
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
server-actions.rst
|
server-actions.rst
|
||||||
|
@@ -86,6 +86,7 @@ server {
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header X-Scheme $scheme;
|
||||||
|
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
@@ -179,3 +179,13 @@ The number of named servers per user can be limited by setting
|
|||||||
```python
|
```python
|
||||||
c.JupyterHub.named_server_limit_per_user = 5
|
c.JupyterHub.named_server_limit_per_user = 5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Switching to Jupyter Server
|
||||||
|
|
||||||
|
[Jupyter Server](https://jupyter-server.readthedocs.io/en/latest/) is a new Tornado Server backend for Jupyter web applications (e.g. JupyterLab 3.0 uses this package as its default backend).
|
||||||
|
|
||||||
|
By default, the single-user notebook server uses the (old) `NotebookApp` from the [notebook](https://github.com/jupyter/notebook) package. You can switch to using Jupyter Server's `ServerApp` backend (this will likely become the default in future releases) by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JUPYTERHUB_SINGLEUSER_APP='jupyter_server.serverapp.ServerApp'
|
||||||
|
```
|
||||||
|
@@ -91,9 +91,9 @@ This example would be configured as follows in `jupyterhub_config.py`:
|
|||||||
```python
|
```python
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
'name': 'cull-idle',
|
'name': 'idle-culler',
|
||||||
'admin': True,
|
'admin': True,
|
||||||
'command': [sys.executable, '/path/to/cull-idle.py', '--timeout']
|
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -123,15 +123,14 @@ For the previous 'cull idle' Service example, these environment variables
|
|||||||
would be passed to the Service when the Hub starts the 'cull idle' Service:
|
would be passed to the Service when the Hub starts the 'cull idle' Service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JUPYTERHUB_SERVICE_NAME: 'cull-idle'
|
JUPYTERHUB_SERVICE_NAME: 'idle-culler'
|
||||||
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
||||||
JUPYTERHUB_API_URL: http://127.0.0.1:8080/hub/api
|
JUPYTERHUB_API_URL: http://127.0.0.1:8080/hub/api
|
||||||
JUPYTERHUB_BASE_URL: https://mydomain[:port]
|
JUPYTERHUB_BASE_URL: https://mydomain[:port]
|
||||||
JUPYTERHUB_SERVICE_PREFIX: /services/cull-idle/
|
JUPYTERHUB_SERVICE_PREFIX: /services/idle-culler/
|
||||||
```
|
```
|
||||||
|
|
||||||
See the JupyterHub GitHub repo for additional information about the
|
See the GitHub repo for additional information about the [jupyterhub_idle_culler][].
|
||||||
[`cull-idle` example](https://github.com/jupyterhub/jupyterhub/tree/master/examples/cull-idle).
|
|
||||||
|
|
||||||
## Externally-Managed Services
|
## Externally-Managed Services
|
||||||
|
|
||||||
@@ -340,7 +339,7 @@ and taking note of the following process:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
r = requests.get(
|
r = requests.get(
|
||||||
'/'.join((["http://127.0.0.1:8081/hub/api",
|
'/'.join(["http://127.0.0.1:8081/hub/api",
|
||||||
"authorizations/cookie/jupyterhub-services",
|
"authorizations/cookie/jupyterhub-services",
|
||||||
quote(encrypted_cookie, safe=''),
|
quote(encrypted_cookie, safe=''),
|
||||||
]),
|
]),
|
||||||
@@ -376,3 +375,4 @@ section on securing the notebook viewer.
|
|||||||
[HubAuth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
[HubAuth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
||||||
[HubAuthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
[HubAuthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||||
|
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler
|
||||||
|
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
version_info = (
|
version_info = (
|
||||||
1,
|
1,
|
||||||
3,
|
4,
|
||||||
0,
|
0,
|
||||||
"", # release (b1, rc1, or "" for final or dev)
|
"", # release (b1, rc1, or "" for final or dev)
|
||||||
"dev", # dev or nothing for beta/rc/stable releases
|
"dev", # dev or nothing for beta/rc/stable releases
|
||||||
|
@@ -6,8 +6,7 @@ import json
|
|||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import needs_scope
|
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
@@ -35,10 +34,12 @@ class _GroupAPIHandler(APIHandler):
|
|||||||
|
|
||||||
|
|
||||||
class GroupListAPIHandler(_GroupAPIHandler):
|
class GroupListAPIHandler(_GroupAPIHandler):
|
||||||
@needs_scope('read:groups') # Todo: Filter allowed here?
|
@needs_scope('read:groups')
|
||||||
def get(self):
|
def get(self, scope_filter=None):
|
||||||
"""List groups"""
|
"""List groups"""
|
||||||
groups = self.db.query(orm.Group)
|
groups = self.db.query(orm.Group)
|
||||||
|
if scope_filter is not None:
|
||||||
|
groups = groups.filter(orm.Group.name.in_(scope_filter))
|
||||||
data = [self.group_model(g) for g in groups]
|
data = [self.group_model(g) for g in groups]
|
||||||
self.write(json.dumps(data))
|
self.write(json.dumps(data))
|
||||||
|
|
||||||
|
@@ -8,8 +8,7 @@ from tornado import web
|
|||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
|
|
||||||
from .._version import __version__
|
from .._version import __version__
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import needs_scope
|
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
@@ -57,8 +56,7 @@ class RootAPIHandler(APIHandler):
|
|||||||
def get(self):
|
def get(self):
|
||||||
"""GET /api/ returns info about the Hub and its API.
|
"""GET /api/ returns info about the Hub and its API.
|
||||||
|
|
||||||
It is not an authenticated endpoint.
|
It is not an authenticated endpoint
|
||||||
|
|
||||||
For now, it just returns the version of JupyterHub itself.
|
For now, it just returns the version of JupyterHub itself.
|
||||||
"""
|
"""
|
||||||
data = {'version': __version__}
|
data = {'version': __version__}
|
||||||
@@ -66,12 +64,12 @@ class RootAPIHandler(APIHandler):
|
|||||||
|
|
||||||
|
|
||||||
class InfoAPIHandler(APIHandler):
|
class InfoAPIHandler(APIHandler):
|
||||||
|
@needs_scope('read:hub')
|
||||||
def get(self):
|
def get(self):
|
||||||
"""GET /api/info returns detailed info about the Hub and its API.
|
"""GET /api/info returns detailed info about the Hub and its API.
|
||||||
|
|
||||||
It is not an authenticated endpoint.
|
Currently, it returns information on the python version, spawner and authenticator.
|
||||||
|
Since this information might be sensitive, it is an authenticated endpoint
|
||||||
For now, it just returns the version of JupyterHub itself.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _class_info(typ):
|
def _class_info(typ):
|
||||||
|
@@ -5,8 +5,7 @@ import json
|
|||||||
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import needs_scope
|
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
|
@@ -9,8 +9,7 @@ import json
|
|||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import needs_scope
|
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
@@ -31,13 +30,16 @@ def service_model(service):
|
|||||||
|
|
||||||
class ServiceListAPIHandler(APIHandler):
|
class ServiceListAPIHandler(APIHandler):
|
||||||
@needs_scope('read:services')
|
@needs_scope('read:services')
|
||||||
def get(self):
|
def get(self, scope_filter=None):
|
||||||
data = {name: service_model(service) for name, service in self.services.items()}
|
data = {name: service_model(service) for name, service in self.services.items()}
|
||||||
|
if scope_filter is not None:
|
||||||
|
data = dict(filter(lambda tup: tup[0] in scope_filter, data.items()))
|
||||||
self.write(json.dumps(data))
|
self.write(json.dumps(data))
|
||||||
|
|
||||||
|
|
||||||
def admin_or_self(method):
|
def admin_or_self(method):
|
||||||
"""Decorator for restricting access to either the target service or admin"""
|
"""Decorator for restricting access to either the target service or admin"""
|
||||||
|
"""***Deprecated in favor of RBAC, use scope-based decorator***"""
|
||||||
|
|
||||||
def decorated_method(self, name):
|
def decorated_method(self, name):
|
||||||
current = self.current_user
|
current = self.current_user
|
||||||
|
@@ -14,13 +14,12 @@ from tornado import web
|
|||||||
from tornado.iostream import StreamClosedError
|
from tornado.iostream import StreamClosedError
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import roles
|
|
||||||
from ..roles import update_roles
|
from ..roles import update_roles
|
||||||
|
from ..scopes import needs_scope
|
||||||
from ..user import User
|
from ..user import User
|
||||||
from ..utils import isoformat
|
from ..utils import isoformat
|
||||||
from ..utils import iterate_until
|
from ..utils import iterate_until
|
||||||
from ..utils import maybe_future
|
from ..utils import maybe_future
|
||||||
from ..utils import needs_scope
|
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
@@ -38,9 +37,11 @@ class SelfAPIHandler(APIHandler):
|
|||||||
user = self.get_current_user_oauth_token()
|
user = self.get_current_user_oauth_token()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
# Later: filter based on scopes.
|
if isinstance(user, orm.Service):
|
||||||
# Perhaps user
|
model = self.service_model(user)
|
||||||
self.write(json.dumps(self.user_model(user)))
|
else:
|
||||||
|
model = self.user_model(user)
|
||||||
|
self.write(json.dumps(model))
|
||||||
|
|
||||||
|
|
||||||
class UserListAPIHandler(APIHandler):
|
class UserListAPIHandler(APIHandler):
|
||||||
@@ -53,7 +54,7 @@ class UserListAPIHandler(APIHandler):
|
|||||||
return any(spawner.ready for spawner in user.spawners.values())
|
return any(spawner.ready for spawner in user.spawners.values())
|
||||||
|
|
||||||
@needs_scope('read:users')
|
@needs_scope('read:users')
|
||||||
def get(self):
|
def get(self, scope_filter=None):
|
||||||
state_filter = self.get_argument("state", None)
|
state_filter = self.get_argument("state", None)
|
||||||
|
|
||||||
# post_filter
|
# post_filter
|
||||||
@@ -94,6 +95,8 @@ class UserListAPIHandler(APIHandler):
|
|||||||
else:
|
else:
|
||||||
# no filter, return all users
|
# no filter, return all users
|
||||||
query = self.db.query(orm.User)
|
query = self.db.query(orm.User)
|
||||||
|
if scope_filter is not None:
|
||||||
|
query = query.filter(orm.User.name.in_(scope_filter))
|
||||||
|
|
||||||
data = [
|
data = [
|
||||||
self.user_model(u, include_servers=True, include_state=True)
|
self.user_model(u, include_servers=True, include_state=True)
|
||||||
@@ -241,6 +244,13 @@ class UserAPIHandler(APIHandler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
await maybe_future(self.authenticator.delete_user(user))
|
await maybe_future(self.authenticator.delete_user(user))
|
||||||
|
|
||||||
|
# allow the spawner to cleanup any persistent resources associated with the user
|
||||||
|
try:
|
||||||
|
await user.spawner.delete_forever()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.error("Error cleaning up persistent resources: %s" % e)
|
||||||
|
|
||||||
# remove from registry
|
# remove from registry
|
||||||
self.users.delete(user)
|
self.users.delete(user)
|
||||||
|
|
||||||
|
@@ -29,6 +29,14 @@ from urllib.parse import urlunparse
|
|||||||
if sys.version_info[:2] < (3, 3):
|
if sys.version_info[:2] < (3, 3):
|
||||||
raise ValueError("Python < 3.3 not supported: %s" % sys.version)
|
raise ValueError("Python < 3.3 not supported: %s" % sys.version)
|
||||||
|
|
||||||
|
# For compatibility with python versions 3.6 or earlier.
|
||||||
|
# asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task.
|
||||||
|
try:
|
||||||
|
asyncio_all_tasks = asyncio.all_tasks
|
||||||
|
asyncio_current_task = asyncio.current_task
|
||||||
|
except AttributeError as e:
|
||||||
|
asyncio_all_tasks = asyncio.Task.all_tasks
|
||||||
|
asyncio_current_task = asyncio.Task.current_task
|
||||||
|
|
||||||
from dateutil.parser import parse as parse_date
|
from dateutil.parser import parse as parse_date
|
||||||
from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader
|
from jinja2 import Environment, FileSystemLoader, PrefixLoader, ChoiceLoader
|
||||||
@@ -393,7 +401,8 @@ class JupyterHub(Application):
|
|||||||
300, help="Interval (in seconds) at which to update last-activity timestamps."
|
300, help="Interval (in seconds) at which to update last-activity timestamps."
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
proxy_check_interval = Integer(
|
proxy_check_interval = Integer(
|
||||||
30, help="Interval (in seconds) at which to check if the proxy is running."
|
5,
|
||||||
|
help="DEPRECATED since version 0.8: Use ConfigurableHTTPProxy.check_running_interval",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
service_check_interval = Integer(
|
service_check_interval = Integer(
|
||||||
60,
|
60,
|
||||||
@@ -707,6 +716,7 @@ class JupyterHub(Application):
|
|||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
_proxy_config_map = {
|
_proxy_config_map = {
|
||||||
|
'proxy_check_interval': 'check_running_interval',
|
||||||
'proxy_cmd': 'command',
|
'proxy_cmd': 'command',
|
||||||
'debug_proxy': 'debug',
|
'debug_proxy': 'debug',
|
||||||
'proxy_auth_token': 'auth_token',
|
'proxy_auth_token': 'auth_token',
|
||||||
@@ -865,15 +875,30 @@ class JupyterHub(Application):
|
|||||||
to reduce the cost of checking authentication tokens.
|
to reduce the cost of checking authentication tokens.
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
cookie_secret = Bytes(
|
cookie_secret = Union(
|
||||||
|
[Bytes(), Unicode()],
|
||||||
help="""The cookie secret to use to encrypt cookies.
|
help="""The cookie secret to use to encrypt cookies.
|
||||||
|
|
||||||
Loaded from the JPY_COOKIE_SECRET env variable by default.
|
Loaded from the JPY_COOKIE_SECRET env variable by default.
|
||||||
|
|
||||||
Should be exactly 256 bits (32 bytes).
|
Should be exactly 256 bits (32 bytes).
|
||||||
"""
|
""",
|
||||||
).tag(config=True, env='JPY_COOKIE_SECRET')
|
).tag(config=True, env='JPY_COOKIE_SECRET')
|
||||||
|
|
||||||
|
@validate('cookie_secret')
|
||||||
|
def _validate_secret_key(self, proposal):
|
||||||
|
"""Coerces strings with even number of hexadecimal characters to bytes."""
|
||||||
|
r = proposal['value']
|
||||||
|
if isinstance(r, str):
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(r)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
"cookie_secret set as a string must contain an even amount of hexadecimal characters."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return r
|
||||||
|
|
||||||
@observe('cookie_secret')
|
@observe('cookie_secret')
|
||||||
def _cookie_secret_check(self, change):
|
def _cookie_secret_check(self, change):
|
||||||
secret = change.new
|
secret = change.new
|
||||||
@@ -1828,6 +1853,7 @@ class JupyterHub(Application):
|
|||||||
async def init_roles(self):
|
async def init_roles(self):
|
||||||
"""Load default and predefined roles into the database"""
|
"""Load default and predefined roles into the database"""
|
||||||
db = self.db
|
db = self.db
|
||||||
|
# tokens are added separately
|
||||||
role_bearers = ['users', 'services', 'groups']
|
role_bearers = ['users', 'services', 'groups']
|
||||||
|
|
||||||
# load default roles
|
# load default roles
|
||||||
@@ -2890,9 +2916,7 @@ class JupyterHub(Application):
|
|||||||
async def shutdown_cancel_tasks(self, sig):
|
async def shutdown_cancel_tasks(self, sig):
|
||||||
"""Cancel all other tasks of the event loop and initiate cleanup"""
|
"""Cancel all other tasks of the event loop and initiate cleanup"""
|
||||||
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
|
self.log.critical("Received signal %s, initiating shutdown...", sig.name)
|
||||||
tasks = [
|
tasks = [t for t in asyncio_all_tasks() if t is not asyncio_current_task()]
|
||||||
t for t in asyncio.Task.all_tasks() if t is not asyncio.Task.current_task()
|
|
||||||
]
|
|
||||||
|
|
||||||
if tasks:
|
if tasks:
|
||||||
self.log.debug("Cancelling pending tasks")
|
self.log.debug("Cancelling pending tasks")
|
||||||
@@ -2905,7 +2929,7 @@ class JupyterHub(Application):
|
|||||||
except StopAsyncIteration as e:
|
except StopAsyncIteration as e:
|
||||||
self.log.error("Caught StopAsyncIteration Exception", exc_info=True)
|
self.log.error("Caught StopAsyncIteration Exception", exc_info=True)
|
||||||
|
|
||||||
tasks = [t for t in asyncio.Task.all_tasks()]
|
tasks = [t for t in asyncio_all_tasks()]
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
self.log.debug("Task status: %s", t)
|
self.log.debug("Task status: %s", t)
|
||||||
await self.cleanup()
|
await self.cleanup()
|
||||||
|
@@ -80,7 +80,7 @@ class BaseHandler(RequestHandler):
|
|||||||
The current user (None if not logged in) may be accessed
|
The current user (None if not logged in) may be accessed
|
||||||
via the `self.current_user` property during the handling of any request.
|
via the `self.current_user` property during the handling of any request.
|
||||||
"""
|
"""
|
||||||
self.scopes = []
|
self.scopes = set()
|
||||||
try:
|
try:
|
||||||
await self.get_current_user()
|
await self.get_current_user()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -430,7 +430,7 @@ class BaseHandler(RequestHandler):
|
|||||||
self._jupyterhub_user = None
|
self._jupyterhub_user = None
|
||||||
self.log.exception("Error getting current user")
|
self.log.exception("Error getting current user")
|
||||||
if self._jupyterhub_user is not None or self.get_current_user_oauth_token():
|
if self._jupyterhub_user is not None or self.get_current_user_oauth_token():
|
||||||
self.scopes = self.settings.get("mock_scopes", [])
|
self.scopes = roles.get_subscopes(*self._jupyterhub_user.roles)
|
||||||
return self._jupyterhub_user
|
return self._jupyterhub_user
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -498,6 +498,11 @@ class BaseHandler(RequestHandler):
|
|||||||
path=url_path_join(self.base_url, 'services'),
|
path=url_path_join(self.base_url, 'services'),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
# clear tornado cookie
|
||||||
|
self.clear_cookie(
|
||||||
|
'_xsrf',
|
||||||
|
**self.settings.get('xsrf_cookie_kwargs', {}),
|
||||||
|
)
|
||||||
# Reset _jupyterhub_user
|
# Reset _jupyterhub_user
|
||||||
self._jupyterhub_user = None
|
self._jupyterhub_user = None
|
||||||
|
|
||||||
@@ -1192,8 +1197,8 @@ class BaseHandler(RequestHandler):
|
|||||||
"""
|
"""
|
||||||
Render jinja2 template
|
Render jinja2 template
|
||||||
|
|
||||||
If sync is set to True, we return an awaitable
|
If sync is set to True, we render the template & return a string
|
||||||
If sync is set to False, we render the template & return a string
|
If sync is set to False, we return an awaitable
|
||||||
"""
|
"""
|
||||||
template_ns = {}
|
template_ns = {}
|
||||||
template_ns.update(self.template_namespace)
|
template_ns.update(self.template_namespace)
|
||||||
|
@@ -17,7 +17,7 @@ from .. import orm
|
|||||||
from ..metrics import SERVER_POLL_DURATION_SECONDS
|
from ..metrics import SERVER_POLL_DURATION_SECONDS
|
||||||
from ..metrics import ServerPollStatus
|
from ..metrics import ServerPollStatus
|
||||||
from ..pagination import Pagination
|
from ..pagination import Pagination
|
||||||
from ..utils import admin_only
|
from ..scopes import needs_scope
|
||||||
from ..utils import maybe_future
|
from ..utils import maybe_future
|
||||||
from ..utils import url_path_join
|
from ..utils import url_path_join
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
@@ -455,9 +455,12 @@ class AdminHandler(BaseHandler):
|
|||||||
"""Render the admin page."""
|
"""Render the admin page."""
|
||||||
|
|
||||||
@web.authenticated
|
@web.authenticated
|
||||||
@admin_only
|
@needs_scope('users')
|
||||||
|
@needs_scope('admin:users')
|
||||||
|
@needs_scope('admin:users:servers')
|
||||||
async def get(self):
|
async def get(self):
|
||||||
page, per_page, offset = Pagination(config=self.config).get_page_args(self)
|
pagination = Pagination(url=self.request.uri, config=self.config)
|
||||||
|
page, per_page, offset = pagination.get_page_args(self)
|
||||||
|
|
||||||
available = {'name', 'admin', 'running', 'last_activity'}
|
available = {'name', 'admin', 'running', 'last_activity'}
|
||||||
default_sort = ['admin', 'name']
|
default_sort = ['admin', 'name']
|
||||||
@@ -500,27 +503,24 @@ class AdminHandler(BaseHandler):
|
|||||||
# get User.col.desc() order objects
|
# get User.col.desc() order objects
|
||||||
ordered = [getattr(c, o)() for c, o in zip(cols, orders)]
|
ordered = [getattr(c, o)() for c, o in zip(cols, orders)]
|
||||||
|
|
||||||
|
query = self.db.query(orm.User).outerjoin(orm.Spawner).distinct(orm.User.id)
|
||||||
|
subquery = query.subquery("users")
|
||||||
users = (
|
users = (
|
||||||
self.db.query(orm.User)
|
self.db.query(orm.User)
|
||||||
|
.select_entity_from(subquery)
|
||||||
.outerjoin(orm.Spawner)
|
.outerjoin(orm.Spawner)
|
||||||
.order_by(*ordered)
|
.order_by(*ordered)
|
||||||
.limit(per_page)
|
.limit(per_page)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
)
|
)
|
||||||
|
|
||||||
users = [self._user_from_orm(u) for u in users]
|
users = [self._user_from_orm(u) for u in users]
|
||||||
|
|
||||||
running = []
|
running = []
|
||||||
for u in users:
|
for u in users:
|
||||||
running.extend(s for s in u.spawners.values() if s.active)
|
running.extend(s for s in u.spawners.values() if s.active)
|
||||||
|
|
||||||
total = self.db.query(orm.User.id).count()
|
pagination.total = query.count()
|
||||||
pagination = Pagination(
|
|
||||||
url=self.request.uri,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
per_page=per_page,
|
|
||||||
config=self.config,
|
|
||||||
)
|
|
||||||
|
|
||||||
auth_state = await self.current_user.get_auth_state()
|
auth_state = await self.current_user.get_auth_state()
|
||||||
html = await self.render_template(
|
html = await self.render_template(
|
||||||
|
@@ -2,7 +2,9 @@
|
|||||||
# 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 json
|
import json
|
||||||
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
from functools import partial
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from urllib.parse import urlunparse
|
from urllib.parse import urlunparse
|
||||||
@@ -132,19 +134,25 @@ def log_request(handler):
|
|||||||
status < 300 and isinstance(handler, (StaticFileHandler, HealthCheckHandler))
|
status < 300 and isinstance(handler, (StaticFileHandler, HealthCheckHandler))
|
||||||
):
|
):
|
||||||
# static-file success and 304 Found are debug-level
|
# static-file success and 304 Found are debug-level
|
||||||
log_method = access_log.debug
|
log_level = logging.DEBUG
|
||||||
elif status < 400:
|
elif status < 400:
|
||||||
log_method = access_log.info
|
log_level = logging.INFO
|
||||||
elif status < 500:
|
elif status < 500:
|
||||||
log_method = access_log.warning
|
log_level = logging.WARNING
|
||||||
else:
|
else:
|
||||||
log_method = access_log.error
|
log_level = logging.ERROR
|
||||||
|
|
||||||
uri = _scrub_uri(request.uri)
|
uri = _scrub_uri(request.uri)
|
||||||
headers = _scrub_headers(request.headers)
|
headers = _scrub_headers(request.headers)
|
||||||
|
|
||||||
request_time = 1000.0 * handler.request.request_time()
|
request_time = 1000.0 * handler.request.request_time()
|
||||||
|
|
||||||
|
# always log slow responses (longer than 1s) at least info-level
|
||||||
|
if request_time >= 1000 and log_level < logging.INFO:
|
||||||
|
log_level = logging.INFO
|
||||||
|
|
||||||
|
log_method = partial(access_log.log, log_level)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = handler.current_user
|
user = handler.current_user
|
||||||
except (HTTPError, RuntimeError):
|
except (HTTPError, RuntimeError):
|
||||||
|
@@ -3,9 +3,9 @@ Prometheus metrics exported by JupyterHub
|
|||||||
|
|
||||||
Read https://prometheus.io/docs/practices/naming/ for naming
|
Read https://prometheus.io/docs/practices/naming/ for naming
|
||||||
conventions for metrics & labels. We generally prefer naming them
|
conventions for metrics & labels. We generally prefer naming them
|
||||||
`<noun>_<verb>_<type_suffix>`. So a histogram that's tracking
|
`jupyterhub_<noun>_<verb>_<type_suffix>`. So a histogram that's tracking
|
||||||
the duration (in seconds) of servers spawning would be called
|
the duration (in seconds) of servers spawning would be called
|
||||||
SERVER_SPAWN_DURATION_SECONDS.
|
jupyterhub_server_spawn_duration_seconds.
|
||||||
|
|
||||||
We also create an Enum for each 'status' type label in every metric
|
We also create an Enum for each 'status' type label in every metric
|
||||||
we collect. This is to make sure that the metrics exist regardless
|
we collect. This is to make sure that the metrics exist regardless
|
||||||
@@ -14,6 +14,10 @@ create them, the metric spawn_duration_seconds{status="failure"}
|
|||||||
will not actually exist until the first failure. This makes dashboarding
|
will not actually exist until the first failure. This makes dashboarding
|
||||||
and alerting difficult, so we explicitly list statuses and create
|
and alerting difficult, so we explicitly list statuses and create
|
||||||
them manually here.
|
them manually here.
|
||||||
|
|
||||||
|
.. versionchanged:: 1.3
|
||||||
|
|
||||||
|
added ``jupyterhub_`` prefix to metric names.
|
||||||
"""
|
"""
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
@@ -21,13 +25,13 @@ from prometheus_client import Gauge
|
|||||||
from prometheus_client import Histogram
|
from prometheus_client import Histogram
|
||||||
|
|
||||||
REQUEST_DURATION_SECONDS = Histogram(
|
REQUEST_DURATION_SECONDS = Histogram(
|
||||||
'request_duration_seconds',
|
'jupyterhub_request_duration_seconds',
|
||||||
'request duration for all HTTP requests',
|
'request duration for all HTTP requests',
|
||||||
['method', 'handler', 'code'],
|
['method', 'handler', 'code'],
|
||||||
)
|
)
|
||||||
|
|
||||||
SERVER_SPAWN_DURATION_SECONDS = Histogram(
|
SERVER_SPAWN_DURATION_SECONDS = Histogram(
|
||||||
'server_spawn_duration_seconds',
|
'jupyterhub_server_spawn_duration_seconds',
|
||||||
'time taken for server spawning operation',
|
'time taken for server spawning operation',
|
||||||
['status'],
|
['status'],
|
||||||
# Use custom bucket sizes, since the default bucket ranges
|
# Use custom bucket sizes, since the default bucket ranges
|
||||||
@@ -36,25 +40,27 @@ SERVER_SPAWN_DURATION_SECONDS = Histogram(
|
|||||||
)
|
)
|
||||||
|
|
||||||
RUNNING_SERVERS = Gauge(
|
RUNNING_SERVERS = Gauge(
|
||||||
'running_servers', 'the number of user servers currently running'
|
'jupyterhub_running_servers', 'the number of user servers currently running'
|
||||||
)
|
)
|
||||||
|
|
||||||
TOTAL_USERS = Gauge('total_users', 'total number of users')
|
TOTAL_USERS = Gauge('jupyterhub_total_users', 'total number of users')
|
||||||
|
|
||||||
CHECK_ROUTES_DURATION_SECONDS = Histogram(
|
CHECK_ROUTES_DURATION_SECONDS = Histogram(
|
||||||
'check_routes_duration_seconds', 'Time taken to validate all routes in proxy'
|
'jupyterhub_check_routes_duration_seconds',
|
||||||
|
'Time taken to validate all routes in proxy',
|
||||||
)
|
)
|
||||||
|
|
||||||
HUB_STARTUP_DURATION_SECONDS = Histogram(
|
HUB_STARTUP_DURATION_SECONDS = Histogram(
|
||||||
'hub_startup_duration_seconds', 'Time taken for Hub to start'
|
'jupyterhub_hub_startup_duration_seconds', 'Time taken for Hub to start'
|
||||||
)
|
)
|
||||||
|
|
||||||
INIT_SPAWNERS_DURATION_SECONDS = Histogram(
|
INIT_SPAWNERS_DURATION_SECONDS = Histogram(
|
||||||
'init_spawners_duration_seconds', 'Time taken for spawners to initialize'
|
'jupyterhub_init_spawners_duration_seconds', 'Time taken for spawners to initialize'
|
||||||
)
|
)
|
||||||
|
|
||||||
PROXY_POLL_DURATION_SECONDS = Histogram(
|
PROXY_POLL_DURATION_SECONDS = Histogram(
|
||||||
'proxy_poll_duration_seconds', 'duration for polling all routes from proxy'
|
'jupyterhub_proxy_poll_duration_seconds',
|
||||||
|
'duration for polling all routes from proxy',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -79,7 +85,9 @@ for s in ServerSpawnStatus:
|
|||||||
|
|
||||||
|
|
||||||
PROXY_ADD_DURATION_SECONDS = Histogram(
|
PROXY_ADD_DURATION_SECONDS = Histogram(
|
||||||
'proxy_add_duration_seconds', 'duration for adding user routes to proxy', ['status']
|
'jupyterhub_proxy_add_duration_seconds',
|
||||||
|
'duration for adding user routes to proxy',
|
||||||
|
['status'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +108,7 @@ for s in ProxyAddStatus:
|
|||||||
|
|
||||||
|
|
||||||
SERVER_POLL_DURATION_SECONDS = Histogram(
|
SERVER_POLL_DURATION_SECONDS = Histogram(
|
||||||
'server_poll_duration_seconds',
|
'jupyterhub_server_poll_duration_seconds',
|
||||||
'time taken to poll if server is running',
|
'time taken to poll if server is running',
|
||||||
['status'],
|
['status'],
|
||||||
)
|
)
|
||||||
@@ -127,7 +135,9 @@ for s in ServerPollStatus:
|
|||||||
|
|
||||||
|
|
||||||
SERVER_STOP_DURATION_SECONDS = Histogram(
|
SERVER_STOP_DURATION_SECONDS = Histogram(
|
||||||
'server_stop_seconds', 'time taken for server stopping operation', ['status']
|
'jupyterhub_server_stop_seconds',
|
||||||
|
'time taken for server stopping operation',
|
||||||
|
['status'],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -148,7 +158,7 @@ for s in ServerStopStatus:
|
|||||||
|
|
||||||
|
|
||||||
PROXY_DELETE_DURATION_SECONDS = Histogram(
|
PROXY_DELETE_DURATION_SECONDS = Histogram(
|
||||||
'proxy_delete_duration_seconds',
|
'jupyterhub_proxy_delete_duration_seconds',
|
||||||
'duration for deleting user routes from proxy',
|
'duration for deleting user routes from proxy',
|
||||||
['status'],
|
['status'],
|
||||||
)
|
)
|
||||||
|
@@ -409,7 +409,7 @@ class Expiring:
|
|||||||
which should be unix timestamp or datetime object
|
which should be unix timestamp or datetime object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
now = utcnow # funciton, must return float timestamp or datetime
|
now = utcnow # function, must return float timestamp or datetime
|
||||||
expires_at = None # must be defined
|
expires_at = None # must be defined
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -987,3 +987,18 @@ def new_session_factory(
|
|||||||
# this off gives us a major performance boost
|
# this off gives us a major performance boost
|
||||||
session_factory = sessionmaker(bind=engine, expire_on_commit=expire_on_commit)
|
session_factory = sessionmaker(bind=engine, expire_on_commit=expire_on_commit)
|
||||||
return session_factory
|
return session_factory
|
||||||
|
|
||||||
|
|
||||||
|
def get_class(resource_name):
|
||||||
|
"""Translates resource string names to ORM classes"""
|
||||||
|
class_dict = {
|
||||||
|
'users': User,
|
||||||
|
'services': Service,
|
||||||
|
'tokens': APIToken,
|
||||||
|
'groups': Group,
|
||||||
|
}
|
||||||
|
if resource_name not in class_dict:
|
||||||
|
raise ValueError(
|
||||||
|
"Kind must be one of %s, not %s" % (", ".join(class_dict), resource_name)
|
||||||
|
)
|
||||||
|
return class_dict[resource_name]
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
"""Basic class to manage pagination utils."""
|
"""Basic class to manage pagination utils."""
|
||||||
# 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.
|
||||||
from traitlets import Bool
|
|
||||||
from traitlets import default
|
from traitlets import default
|
||||||
from traitlets import Integer
|
from traitlets import Integer
|
||||||
from traitlets import observe
|
from traitlets import observe
|
||||||
@@ -81,13 +80,13 @@ class Pagination(Configurable):
|
|||||||
try:
|
try:
|
||||||
self.per_page = int(per_page)
|
self.per_page = int(per_page)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.per_page = self._default_per_page
|
self.per_page = self.default_per_page
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.page = int(page)
|
self.page = int(page)
|
||||||
if self.page < 1:
|
if self.page < 1:
|
||||||
self.page = 1
|
self.page = 1
|
||||||
except:
|
except Exception:
|
||||||
self.page = 1
|
self.page = 1
|
||||||
|
|
||||||
return self.page, self.per_page, self.per_page * (self.page - 1)
|
return self.page, self.per_page, self.per_page * (self.page - 1)
|
||||||
|
@@ -450,7 +450,11 @@ class ConfigurableHTTPProxy(Proxy):
|
|||||||
Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default.
|
Loaded from the CONFIGPROXY_AUTH_TOKEN env variable by default.
|
||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
check_running_interval = Integer(5, config=True)
|
check_running_interval = Integer(
|
||||||
|
5,
|
||||||
|
help="Interval (in seconds) at which to check if the proxy is running.",
|
||||||
|
config=True,
|
||||||
|
)
|
||||||
|
|
||||||
@default('auth_token')
|
@default('auth_token')
|
||||||
def _auth_token_default(self):
|
def _auth_token_default(self):
|
||||||
|
@@ -10,7 +10,6 @@ from . import orm
|
|||||||
|
|
||||||
|
|
||||||
def get_default_roles():
|
def get_default_roles():
|
||||||
|
|
||||||
"""Returns a list of default role dictionaries"""
|
"""Returns a list of default role dictionaries"""
|
||||||
|
|
||||||
default_roles = [
|
default_roles = [
|
||||||
@@ -46,7 +45,6 @@ def get_default_roles():
|
|||||||
|
|
||||||
|
|
||||||
def get_scopes():
|
def get_scopes():
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Returns a dictionary of scopes:
|
Returns a dictionary of scopes:
|
||||||
scopes.keys() = scopes of highest level and scopes that have their own subscopes
|
scopes.keys() = scopes of highest level and scopes that have their own subscopes
|
||||||
@@ -77,7 +75,6 @@ def get_scopes():
|
|||||||
|
|
||||||
|
|
||||||
def expand_scope(scopename):
|
def expand_scope(scopename):
|
||||||
|
|
||||||
"""Returns a set of all subscopes"""
|
"""Returns a set of all subscopes"""
|
||||||
|
|
||||||
scopes = get_scopes()
|
scopes = get_scopes()
|
||||||
@@ -106,7 +103,6 @@ def expand_scope(scopename):
|
|||||||
|
|
||||||
|
|
||||||
def get_subscopes(*args):
|
def get_subscopes(*args):
|
||||||
|
|
||||||
"""Returns a set of all available subscopes for a specified role or list of roles"""
|
"""Returns a set of all available subscopes for a specified role or list of roles"""
|
||||||
|
|
||||||
scope_list = []
|
scope_list = []
|
||||||
@@ -120,7 +116,6 @@ def get_subscopes(*args):
|
|||||||
|
|
||||||
|
|
||||||
def add_role(db, role_dict):
|
def add_role(db, role_dict):
|
||||||
|
|
||||||
"""Adds a new role to database or modifies an existing one"""
|
"""Adds a new role to database or modifies an existing one"""
|
||||||
|
|
||||||
default_roles = get_default_roles()
|
default_roles = get_default_roles()
|
||||||
@@ -151,30 +146,23 @@ def add_role(db, role_dict):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def get_orm_class(kind):
|
def remove_role(db, rolename):
|
||||||
if kind == 'users':
|
"""Removes a role from database"""
|
||||||
Class = orm.User
|
|
||||||
elif kind == 'services':
|
|
||||||
Class = orm.Service
|
|
||||||
elif kind == 'tokens':
|
|
||||||
Class = orm.APIToken
|
|
||||||
elif kind == 'groups':
|
|
||||||
Class = orm.Group
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
"kind must be users, services, tokens or groups, not %r" % kind
|
|
||||||
)
|
|
||||||
|
|
||||||
return Class
|
role = orm.Role.find(db, rolename)
|
||||||
|
if role:
|
||||||
|
db.delete(role)
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
raise NameError('Cannot remove role %r that does not exist', rolename)
|
||||||
|
|
||||||
|
|
||||||
def existing_only(func):
|
def existing_only(func):
|
||||||
|
|
||||||
"""Decorator for checking if objects and roles exist"""
|
"""Decorator for checking if objects and roles exist"""
|
||||||
|
|
||||||
def check_existence(db, objname, kind, rolename):
|
def check_existence(db, objname, kind, rolename):
|
||||||
|
|
||||||
Class = get_orm_class(kind)
|
Class = orm.get_class(kind)
|
||||||
obj = Class.find(db, objname)
|
obj = Class.find(db, objname)
|
||||||
role = orm.Role.find(db, rolename)
|
role = orm.Role.find(db, rolename)
|
||||||
|
|
||||||
@@ -190,7 +178,6 @@ def existing_only(func):
|
|||||||
|
|
||||||
@existing_only
|
@existing_only
|
||||||
def add_obj(db, objname, kind, rolename):
|
def add_obj(db, objname, kind, rolename):
|
||||||
|
|
||||||
"""Adds a role for users, services, tokens or groups"""
|
"""Adds a role for users, services, tokens or groups"""
|
||||||
|
|
||||||
if kind == 'tokens':
|
if kind == 'tokens':
|
||||||
@@ -206,7 +193,6 @@ def add_obj(db, objname, kind, rolename):
|
|||||||
|
|
||||||
@existing_only
|
@existing_only
|
||||||
def remove_obj(db, objname, kind, rolename):
|
def remove_obj(db, objname, kind, rolename):
|
||||||
|
|
||||||
"""Removes a role for users, services or tokens"""
|
"""Removes a role for users, services or tokens"""
|
||||||
|
|
||||||
if kind == 'tokens':
|
if kind == 'tokens':
|
||||||
@@ -223,7 +209,6 @@ def remove_obj(db, objname, kind, rolename):
|
|||||||
|
|
||||||
|
|
||||||
def switch_default_role(db, obj, kind, admin):
|
def switch_default_role(db, obj, kind, admin):
|
||||||
|
|
||||||
"""Switch between default user and admin roles for users/services"""
|
"""Switch between default user and admin roles for users/services"""
|
||||||
|
|
||||||
user_role = orm.Role.find(db, 'user')
|
user_role = orm.Role.find(db, 'user')
|
||||||
@@ -270,11 +255,10 @@ def check_token_roles(db, token, role):
|
|||||||
|
|
||||||
|
|
||||||
def update_roles(db, obj, kind, roles=None):
|
def update_roles(db, obj, kind, roles=None):
|
||||||
|
|
||||||
"""Updates object's roles if specified,
|
"""Updates object's roles if specified,
|
||||||
assigns default if no roles specified"""
|
assigns default if no roles specified"""
|
||||||
|
|
||||||
Class = get_orm_class(kind)
|
Class = orm.get_class(kind)
|
||||||
user_role = orm.Role.find(db, 'user')
|
user_role = orm.Role.find(db, 'user')
|
||||||
admin_role = orm.Role.find(db, 'admin')
|
admin_role = orm.Role.find(db, 'admin')
|
||||||
|
|
||||||
@@ -346,7 +330,7 @@ def check_for_default_roles(db, bearer):
|
|||||||
"""Checks that role bearers have at least one role (default if none).
|
"""Checks that role bearers have at least one role (default if none).
|
||||||
Groups can be without a role"""
|
Groups can be without a role"""
|
||||||
|
|
||||||
Class = get_orm_class(bearer)
|
Class = orm.get_class(bearer)
|
||||||
if Class == orm.Group:
|
if Class == orm.Group:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@@ -360,15 +344,12 @@ def check_for_default_roles(db, bearer):
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def mock_roles(db, name, kind):
|
def mock_roles(app, name, kind):
|
||||||
|
|
||||||
"""Loads and assigns default roles for mocked objects"""
|
"""Loads and assigns default roles for mocked objects"""
|
||||||
|
Class = orm.get_class(kind)
|
||||||
Class = get_orm_class(kind)
|
obj = Class.find(app.db, name=name)
|
||||||
|
|
||||||
obj = Class.find(db, name=name)
|
|
||||||
default_roles = get_default_roles()
|
default_roles = get_default_roles()
|
||||||
for role in default_roles:
|
for role in default_roles:
|
||||||
add_role(db, role)
|
add_role(app.db, role)
|
||||||
app_log.info('Assigning default roles to mocked %s: %s', kind[:-1], name)
|
app_log.info('Assigning default roles to mocked %s: %s', kind[:-1], name)
|
||||||
update_roles(db=db, obj=obj, kind=kind)
|
update_roles(db=app.db, obj=obj, kind=kind)
|
||||||
|
198
jupyterhub/scopes.py
Normal file
198
jupyterhub/scopes.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import functools
|
||||||
|
import inspect
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from tornado import web
|
||||||
|
from tornado.log import app_log
|
||||||
|
|
||||||
|
from . import orm
|
||||||
|
|
||||||
|
|
||||||
|
class Scope(Enum):
|
||||||
|
ALL = True
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_scopes(name):
|
||||||
|
"""
|
||||||
|
Scopes have a metascope 'all' that should be expanded to everything a user can do.
|
||||||
|
At the moment that is a user-filtered version (optional read) access to
|
||||||
|
users
|
||||||
|
users:name
|
||||||
|
users:groups
|
||||||
|
users:activity
|
||||||
|
users:servers
|
||||||
|
users:tokens
|
||||||
|
"""
|
||||||
|
scope_list = [
|
||||||
|
'users',
|
||||||
|
'users:name',
|
||||||
|
'users:groups',
|
||||||
|
'users:activity',
|
||||||
|
'users:servers',
|
||||||
|
'users:tokens',
|
||||||
|
]
|
||||||
|
scope_list.extend(['read:' + scope for scope in scope_list])
|
||||||
|
return {"{}!user={}".format(scope, name) for scope in scope_list}
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_scope_expansion(filter_, filter_value, sub_scope):
|
||||||
|
"""
|
||||||
|
Check if there is a requirements to expand the `group` scope to individual `user` scopes.
|
||||||
|
Assumptions:
|
||||||
|
filter_ != Scope.ALL
|
||||||
|
"""
|
||||||
|
if not (filter_ == 'user' and 'group' in sub_scope):
|
||||||
|
return False
|
||||||
|
if 'user' in sub_scope:
|
||||||
|
return filter_value not in sub_scope['user']
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _check_user_in_expanded_scope(handler, user_name, scope_group_names):
|
||||||
|
"""Check if username is present in set of allowed groups"""
|
||||||
|
user = handler.find_user(user_name)
|
||||||
|
if user is None:
|
||||||
|
raise web.HTTPError(404, "No access to resources or resources not found")
|
||||||
|
group_names = {group.name for group in user.groups} # Todo: Replace with SQL query
|
||||||
|
return bool(set(scope_group_names) & group_names)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_scope_filter(db, req_scope, sub_scope):
|
||||||
|
"""Produce a filter for `*ListAPIHandlers* so that get method knows which models to return"""
|
||||||
|
scope_translator = {
|
||||||
|
'read:users': 'users',
|
||||||
|
'read:services': 'services',
|
||||||
|
'read:groups': 'groups',
|
||||||
|
}
|
||||||
|
if req_scope not in scope_translator:
|
||||||
|
raise AttributeError("Internal error: inconsistent scope situation")
|
||||||
|
kind = scope_translator[req_scope]
|
||||||
|
Resource = orm.get_class(kind)
|
||||||
|
sub_scope_values = next(iter(sub_scope.values()))
|
||||||
|
query = db.query(Resource).filter(Resource.name.in_(sub_scope_values))
|
||||||
|
scope_filter = {entry.name for entry in query.all()}
|
||||||
|
if 'group' in sub_scope and kind == 'users':
|
||||||
|
groups = orm.Group.name.in_(sub_scope['group'])
|
||||||
|
users_in_groups = db.query(orm.User).join(orm.Group.users).filter(groups)
|
||||||
|
scope_filter |= {user.name for user in users_in_groups}
|
||||||
|
return scope_filter
|
||||||
|
|
||||||
|
|
||||||
|
def _check_scope(api_handler, req_scope, scopes, **kwargs):
|
||||||
|
"""Check if scopes satisfy requirements
|
||||||
|
Returns either Scope.ALL for unrestricted access, Scope.NONE for refused access or
|
||||||
|
an iterable with a filter
|
||||||
|
"""
|
||||||
|
# Parse user name and server name together
|
||||||
|
if 'user' in kwargs and 'server' in kwargs:
|
||||||
|
kwargs['server'] = "{}/{}".format(kwargs['user'], kwargs['server'])
|
||||||
|
if req_scope not in scopes:
|
||||||
|
return False
|
||||||
|
if scopes[req_scope] == Scope.ALL:
|
||||||
|
return True
|
||||||
|
# Apply filters
|
||||||
|
sub_scope = scopes[req_scope]
|
||||||
|
if 'scope_filter' in kwargs:
|
||||||
|
scope_filter = _get_scope_filter(api_handler.db, req_scope, sub_scope)
|
||||||
|
return scope_filter
|
||||||
|
else:
|
||||||
|
if not kwargs:
|
||||||
|
return False # Separated from 404 error below because in this case we don't leak information
|
||||||
|
# Interface change: Now can have multiple filters
|
||||||
|
for (filter_, filter_value) in kwargs.items():
|
||||||
|
if filter_ in sub_scope and filter_value in sub_scope[filter_]:
|
||||||
|
return True
|
||||||
|
if _needs_scope_expansion(filter_, filter_value, sub_scope):
|
||||||
|
group_names = sub_scope['group']
|
||||||
|
if _check_user_in_expanded_scope(
|
||||||
|
api_handler, filter_value, group_names
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
raise web.HTTPError(404, "No access to resources or resources not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_scopes(scope_list):
|
||||||
|
"""
|
||||||
|
Parses scopes and filters in something akin to JSON style
|
||||||
|
|
||||||
|
For instance, scope list ["users", "groups!group=foo", "users:servers!server=bar", "users:servers!server=baz"]
|
||||||
|
would lead to scope model
|
||||||
|
{
|
||||||
|
"users":scope.ALL,
|
||||||
|
"users:admin":{
|
||||||
|
"user":[
|
||||||
|
"alice"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users:servers":{
|
||||||
|
"server":[
|
||||||
|
"bar",
|
||||||
|
"baz"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
parsed_scopes = {}
|
||||||
|
for scope in scope_list:
|
||||||
|
base_scope, _, filter_ = scope.partition('!')
|
||||||
|
if not filter_:
|
||||||
|
parsed_scopes[base_scope] = Scope.ALL
|
||||||
|
elif base_scope not in parsed_scopes:
|
||||||
|
parsed_scopes[base_scope] = {}
|
||||||
|
if parsed_scopes[base_scope] != Scope.ALL:
|
||||||
|
key, _, val = filter_.partition('=')
|
||||||
|
if key not in parsed_scopes[base_scope]:
|
||||||
|
parsed_scopes[base_scope][key] = []
|
||||||
|
parsed_scopes[base_scope][key].append(val)
|
||||||
|
return parsed_scopes
|
||||||
|
|
||||||
|
|
||||||
|
def needs_scope(scope):
|
||||||
|
"""Decorator to restrict access to users or services with the required scope"""
|
||||||
|
|
||||||
|
def scope_decorator(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def _auth_func(self, *args, **kwargs):
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
bound_sig = sig.bind(self, *args, **kwargs)
|
||||||
|
bound_sig.apply_defaults()
|
||||||
|
s_kwargs = {}
|
||||||
|
for resource in {'user', 'server', 'group', 'service'}:
|
||||||
|
resource_name = resource + '_name'
|
||||||
|
if resource_name in bound_sig.arguments:
|
||||||
|
resource_value = bound_sig.arguments[resource_name]
|
||||||
|
s_kwargs[resource] = resource_value
|
||||||
|
if 'scope_filter' in bound_sig.arguments:
|
||||||
|
s_kwargs['scope_filter'] = None
|
||||||
|
if 'all' in self.scopes and self.current_user:
|
||||||
|
self.scopes |= get_user_scopes(self.current_user.name)
|
||||||
|
parsed_scopes = _parse_scopes(self.scopes)
|
||||||
|
scope_filter = _check_scope(self, scope, parsed_scopes, **s_kwargs)
|
||||||
|
# todo: This checks if True/False or set of resource names. Can be improved
|
||||||
|
if isinstance(scope_filter, set):
|
||||||
|
kwargs['scope_filter'] = scope_filter
|
||||||
|
if scope_filter:
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
else:
|
||||||
|
# catching attr error occurring for older_requirements test
|
||||||
|
# could be done more ellegantly?
|
||||||
|
try:
|
||||||
|
request_path = self.request.path
|
||||||
|
except AttributeError:
|
||||||
|
request_path = 'the requested API'
|
||||||
|
app_log.warning(
|
||||||
|
"Not authorizing access to {}. Requires scope {}, not derived from scopes [{}]".format(
|
||||||
|
request_path, scope, ", ".join(self.scopes)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise web.HTTPError(
|
||||||
|
403,
|
||||||
|
"Action is not authorized with current scopes; requires {}".format(
|
||||||
|
scope
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _auth_func
|
||||||
|
|
||||||
|
return scope_decorator
|
@@ -353,8 +353,9 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
return options_form
|
return options_form
|
||||||
|
|
||||||
def options_from_form(self, form_data):
|
options_from_form = Callable(
|
||||||
"""Interpret HTTP form data
|
help="""
|
||||||
|
Interpret HTTP form data
|
||||||
|
|
||||||
Form data will always arrive as a dict of lists of strings.
|
Form data will always arrive as a dict of lists of strings.
|
||||||
Override this function to understand single-values, numbers, etc.
|
Override this function to understand single-values, numbers, etc.
|
||||||
@@ -378,7 +379,14 @@ class Spawner(LoggingConfigurable):
|
|||||||
(with additional support for bytes in case of uploaded file data),
|
(with additional support for bytes in case of uploaded file data),
|
||||||
and any non-bytes non-jsonable values will be replaced with None
|
and any non-bytes non-jsonable values will be replaced with None
|
||||||
if the user_options are re-used.
|
if the user_options are re-used.
|
||||||
"""
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
@default("options_from_form")
|
||||||
|
def _options_from_form(self):
|
||||||
|
return self._default_options_from_form
|
||||||
|
|
||||||
|
def _default_options_from_form(self, form_data):
|
||||||
return form_data
|
return form_data
|
||||||
|
|
||||||
def options_from_query(self, query_data):
|
def options_from_query(self, query_data):
|
||||||
|
@@ -246,7 +246,7 @@ def _mockservice(request, app, url=False):
|
|||||||
):
|
):
|
||||||
app.services = [spec]
|
app.services = [spec]
|
||||||
app.init_services()
|
app.init_services()
|
||||||
mock_roles(app.db, name, 'services')
|
mock_roles(app, name, 'services')
|
||||||
assert name in app._service_map
|
assert name in app._service_map
|
||||||
service = app._service_map[name]
|
service = app._service_map[name]
|
||||||
|
|
||||||
|
@@ -78,13 +78,6 @@ def mock_open_session(username, service, encoding):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def mock_role(app, role='admin', name=None):
|
|
||||||
scopes = get_scopes(role)
|
|
||||||
if name is not None:
|
|
||||||
scopes = [scope.format(username=name) for scope in scopes]
|
|
||||||
return mock.patch.dict(app.tornado_settings, {'mock_scopes': scopes})
|
|
||||||
|
|
||||||
|
|
||||||
class MockSpawner(SimpleLocalProcessSpawner):
|
class MockSpawner(SimpleLocalProcessSpawner):
|
||||||
"""Base mock spawner
|
"""Base mock spawner
|
||||||
|
|
||||||
@@ -102,6 +95,16 @@ class MockSpawner(SimpleLocalProcessSpawner):
|
|||||||
def _cmd_default(self):
|
def _cmd_default(self):
|
||||||
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
||||||
|
|
||||||
|
async def delete_forever(self):
|
||||||
|
"""Called when a user is deleted.
|
||||||
|
|
||||||
|
This can do things like request removal of resources such as persistent storage.
|
||||||
|
Only called on stopped spawners, and is likely the last action ever taken for the user.
|
||||||
|
|
||||||
|
Will only be called once on the user's default Spawner.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
use_this_api_token = None
|
use_this_api_token = None
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
@@ -14,11 +14,9 @@ from pytest import mark
|
|||||||
|
|
||||||
import jupyterhub
|
import jupyterhub
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from .. import roles
|
|
||||||
from ..objects import Server
|
from ..objects import Server
|
||||||
from ..utils import url_path_join as ujoin
|
from ..utils import url_path_join as ujoin
|
||||||
from ..utils import utcnow
|
from ..utils import utcnow
|
||||||
from .mocking import mock_role
|
|
||||||
from .mocking import public_host
|
from .mocking import public_host
|
||||||
from .mocking import public_url
|
from .mocking import public_url
|
||||||
from .utils import add_user
|
from .utils import add_user
|
||||||
@@ -27,6 +25,7 @@ from .utils import async_requests
|
|||||||
from .utils import auth_header
|
from .utils import auth_header
|
||||||
from .utils import find_user
|
from .utils import find_user
|
||||||
|
|
||||||
|
|
||||||
# --------------------
|
# --------------------
|
||||||
# Authentication tests
|
# Authentication tests
|
||||||
# --------------------
|
# --------------------
|
||||||
@@ -175,15 +174,20 @@ async def test_get_users(app):
|
|||||||
|
|
||||||
users = sorted(r.json(), key=lambda d: d['name'])
|
users = sorted(r.json(), key=lambda d: d['name'])
|
||||||
users = [normalize_user(u) for u in users]
|
users = [normalize_user(u) for u in users]
|
||||||
|
user_model = {
|
||||||
|
'name': 'user',
|
||||||
|
'admin': False,
|
||||||
|
'roles': ['user'],
|
||||||
|
'last_activity': None,
|
||||||
|
}
|
||||||
assert users == [
|
assert users == [
|
||||||
fill_user({'name': 'admin', 'admin': True, 'roles': ['admin']}),
|
fill_user({'name': 'admin', 'admin': True, 'roles': ['admin']}),
|
||||||
fill_user(
|
fill_user(user_model),
|
||||||
{'name': 'user', 'admin': False, 'roles': ['user'], 'last_activity': None}
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
with mock_role(app, 'user'):
|
r = await api_request(app, 'users', headers=auth_header(db, 'user'))
|
||||||
r = await api_request(app, 'users', headers=auth_header(db, 'user'))
|
assert r.status_code == 200
|
||||||
assert r.status_code == 403
|
r_user_model = r.json()[0]
|
||||||
|
assert r_user_model['name'] == user_model['name']
|
||||||
|
|
||||||
|
|
||||||
@mark.user
|
@mark.user
|
||||||
@@ -284,15 +288,25 @@ async def test_get_self(app):
|
|||||||
assert model['name'] == u.name
|
assert model['name'] == u.name
|
||||||
|
|
||||||
# invalid auth gets 403
|
# invalid auth gets 403
|
||||||
with mock_role(app, 'user'):
|
r = await api_request(
|
||||||
r = await api_request(
|
app,
|
||||||
app,
|
'user',
|
||||||
'user',
|
headers={'Authorization': 'token notvalid'},
|
||||||
headers={'Authorization': 'token notvalid'},
|
)
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_self_service(app, mockservice):
|
||||||
|
r = await api_request(
|
||||||
|
app, "user", headers={"Authorization": f"token {mockservice.api_token}"}
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
service_info = r.json()
|
||||||
|
|
||||||
|
assert service_info['kind'] == 'service'
|
||||||
|
assert service_info['name'] == mockservice.name
|
||||||
|
|
||||||
|
|
||||||
@mark.user
|
@mark.user
|
||||||
@mark.role
|
@mark.role
|
||||||
async def test_add_user(app):
|
async def test_add_user(app):
|
||||||
@@ -314,12 +328,11 @@ async def test_add_user(app):
|
|||||||
async def test_get_user(app):
|
async def test_get_user(app):
|
||||||
name = 'user'
|
name = 'user'
|
||||||
_ = await api_request(app, 'users', name, headers=auth_header(app.db, name))
|
_ = await api_request(app, 'users', name, headers=auth_header(app.db, name))
|
||||||
with mock_role(app, role=name, name=name):
|
r = await api_request(
|
||||||
r = await api_request(
|
app,
|
||||||
app,
|
'users',
|
||||||
'users',
|
name,
|
||||||
name,
|
)
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
user = normalize_user(r.json())
|
user = normalize_user(r.json())
|
||||||
@@ -518,15 +531,14 @@ async def test_user_set_auth_state(app, auth_state_enabled):
|
|||||||
assert user.name == name
|
assert user.name == name
|
||||||
user_auth_state = await user.get_auth_state()
|
user_auth_state = await user.get_auth_state()
|
||||||
assert user_auth_state is None
|
assert user_auth_state is None
|
||||||
with mock_role(app, 'user'):
|
r = await api_request(
|
||||||
r = await api_request(
|
app,
|
||||||
app,
|
'users',
|
||||||
'users',
|
name,
|
||||||
name,
|
method='patch',
|
||||||
method='patch',
|
data=json.dumps({'auth_state': auth_state}),
|
||||||
data=json.dumps({'auth_state': auth_state}),
|
headers=auth_header(app.db, name),
|
||||||
headers=auth_header(app.db, name),
|
)
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
user_auth_state = await user.get_auth_state()
|
user_auth_state = await user.get_auth_state()
|
||||||
assert user_auth_state is None
|
assert user_auth_state is None
|
||||||
@@ -1348,16 +1360,15 @@ async def test_token_authenticator_noauth(app):
|
|||||||
"""Create a token for a user relying on Authenticator.authenticate and no auth header"""
|
"""Create a token for a user relying on Authenticator.authenticate and no auth header"""
|
||||||
name = 'user'
|
name = 'user'
|
||||||
data = {'auth': {'username': name, 'password': name}}
|
data = {'auth': {'username': name, 'password': name}}
|
||||||
with mock_role(app, 'admin'):
|
r = await api_request(
|
||||||
r = await api_request(
|
app,
|
||||||
app,
|
'users',
|
||||||
'users',
|
name,
|
||||||
name,
|
'tokens',
|
||||||
'tokens',
|
method='post',
|
||||||
method='post',
|
data=json.dumps(data) if data else None,
|
||||||
data=json.dumps(data) if data else None,
|
noauth=True,
|
||||||
noauth=True,
|
)
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert 'token' in reply
|
assert 'token' in reply
|
||||||
@@ -1372,16 +1383,15 @@ async def test_token_authenticator_dict_noauth(app):
|
|||||||
app.authenticator.auth_state = {'who': 'cares'}
|
app.authenticator.auth_state = {'who': 'cares'}
|
||||||
name = 'user'
|
name = 'user'
|
||||||
data = {'auth': {'username': name, 'password': name}}
|
data = {'auth': {'username': name, 'password': name}}
|
||||||
with mock_role(app, 'user'):
|
r = await api_request(
|
||||||
r = await api_request(
|
app,
|
||||||
app,
|
'users',
|
||||||
'users',
|
name,
|
||||||
name,
|
'tokens',
|
||||||
'tokens',
|
method='post',
|
||||||
method='post',
|
data=json.dumps(data) if data else None,
|
||||||
data=json.dumps(data) if data else None,
|
noauth=True,
|
||||||
noauth=True,
|
)
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert 'token' in reply
|
assert 'token' in reply
|
||||||
@@ -1396,7 +1406,7 @@ async def test_token_authenticator_dict_noauth(app):
|
|||||||
[
|
[
|
||||||
('admin', 'other', 200),
|
('admin', 'other', 200),
|
||||||
('admin', 'missing', 404),
|
('admin', 'missing', 404),
|
||||||
('user', 'other', 403),
|
('user', 'other', 404),
|
||||||
('user', 'user', 200),
|
('user', 'user', 200),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1405,8 +1415,7 @@ async def test_token_list(app, as_user, for_user, status):
|
|||||||
if for_user != 'missing':
|
if for_user != 'missing':
|
||||||
for_user_obj = add_user(app.db, app, name=for_user)
|
for_user_obj = add_user(app.db, app, name=for_user)
|
||||||
headers = {'Authorization': 'token %s' % u.new_api_token()}
|
headers = {'Authorization': 'token %s' % u.new_api_token()}
|
||||||
with mock_role(app, role=as_user, name=as_user):
|
r = await api_request(app, 'users', for_user, 'tokens', headers=headers)
|
||||||
r = await api_request(app, 'users', for_user, 'tokens', headers=headers)
|
|
||||||
assert r.status_code == status
|
assert r.status_code == status
|
||||||
if status != 200:
|
if status != 200:
|
||||||
return
|
return
|
||||||
@@ -1417,10 +1426,9 @@ async def test_token_list(app, as_user, for_user, status):
|
|||||||
assert all(token['user'] == for_user for token in reply['oauth_tokens'])
|
assert all(token['user'] == for_user for token in reply['oauth_tokens'])
|
||||||
# validate individual token ids
|
# validate individual token ids
|
||||||
for token in reply['api_tokens'] + reply['oauth_tokens']:
|
for token in reply['api_tokens'] + reply['oauth_tokens']:
|
||||||
with mock_role(app, role=as_user, name=as_user):
|
r = await api_request(
|
||||||
r = await api_request(
|
app, 'users', for_user, 'tokens', token['id'], headers=headers
|
||||||
app, 'users', for_user, 'tokens', token['id'], headers=headers
|
)
|
||||||
)
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
reply = r.json()
|
reply = r.json()
|
||||||
assert normalize_token(reply) == normalize_token(token)
|
assert normalize_token(reply) == normalize_token(token)
|
||||||
@@ -1608,8 +1616,7 @@ async def test_get_services(app, mockservice_url):
|
|||||||
'display': True,
|
'display': True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
with mock_role(app, 'user'):
|
r = await api_request(app, 'services', headers=auth_header(db, 'user'))
|
||||||
r = await api_request(app, 'services', headers=auth_header(db, 'user'))
|
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@@ -1633,17 +1640,15 @@ async def test_get_service(app, mockservice_url):
|
|||||||
'info': {},
|
'info': {},
|
||||||
'display': True,
|
'display': True,
|
||||||
}
|
}
|
||||||
with mock_role(app, 'service'):
|
r = await api_request(
|
||||||
r = await api_request(
|
app,
|
||||||
app,
|
'services/%s' % mockservice.name,
|
||||||
'services/%s' % mockservice.name,
|
headers={'Authorization': 'token %s' % mockservice.api_token},
|
||||||
headers={'Authorization': 'token %s' % mockservice.api_token},
|
)
|
||||||
)
|
r.raise_for_status()
|
||||||
r.raise_for_status()
|
r = await api_request(
|
||||||
with mock_role(app, 'user'):
|
app, 'services/%s' % mockservice.name, headers=auth_header(db, 'user')
|
||||||
r = await api_request(
|
)
|
||||||
app, 'services/%s' % mockservice.name, headers=auth_header(db, 'user')
|
|
||||||
)
|
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
@@ -1654,7 +1659,7 @@ async def test_root_api(app):
|
|||||||
if app.internal_ssl:
|
if app.internal_ssl:
|
||||||
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
|
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
|
||||||
kwargs["verify"] = app.internal_ssl_ca
|
kwargs["verify"] = app.internal_ssl_ca
|
||||||
r = await async_requests.get(url, **kwargs)
|
r = await api_request(app, bypass_proxy=True)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
expected = {'version': jupyterhub.__version__}
|
expected = {'version': jupyterhub.__version__}
|
||||||
assert r.json() == expected
|
assert r.json() == expected
|
||||||
@@ -1691,15 +1696,14 @@ async def test_info(app):
|
|||||||
|
|
||||||
async def test_update_activity_403(app, user, admin_user):
|
async def test_update_activity_403(app, user, admin_user):
|
||||||
token = user.new_api_token()
|
token = user.new_api_token()
|
||||||
with mock_role(app, 'user'):
|
r = await api_request(
|
||||||
r = await api_request(
|
app,
|
||||||
app,
|
"users/{}/activity".format(admin_user.name),
|
||||||
"users/{}/activity".format(admin_user.name),
|
headers={"Authorization": "token {}".format(token)},
|
||||||
headers={"Authorization": "token {}".format(token)},
|
data="{}",
|
||||||
data="{}",
|
method="post",
|
||||||
method="post",
|
)
|
||||||
)
|
assert r.status_code == 404
|
||||||
assert r.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_activity_admin(app, user, admin_user):
|
async def test_update_activity_admin(app, user, admin_user):
|
||||||
|
@@ -199,6 +199,18 @@ def test_cookie_secret_env(tmpdir, request):
|
|||||||
assert not os.path.exists(hub.cookie_secret_file)
|
assert not os.path.exists(hub.cookie_secret_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cookie_secret_string_():
|
||||||
|
cfg = Config()
|
||||||
|
|
||||||
|
cfg.JupyterHub.cookie_secret = "not hex"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
JupyterHub(config=cfg)
|
||||||
|
|
||||||
|
cfg.JupyterHub.cookie_secret = "abc123"
|
||||||
|
app = JupyterHub(config=cfg)
|
||||||
|
assert app.cookie_secret == binascii.a2b_hex('abc123')
|
||||||
|
|
||||||
|
|
||||||
async def test_load_groups(tmpdir, request):
|
async def test_load_groups(tmpdir, request):
|
||||||
to_load = {
|
to_load = {
|
||||||
'blue': ['cyclops', 'rogue', 'wolverine'],
|
'blue': ['cyclops', 'rogue', 'wolverine'],
|
||||||
|
@@ -6,11 +6,20 @@ from traitlets.config import Config
|
|||||||
from jupyterhub.pagination import Pagination
|
from jupyterhub.pagination import Pagination
|
||||||
|
|
||||||
|
|
||||||
def test_per_page_bounds():
|
@mark.parametrize(
|
||||||
|
"per_page, max_per_page, expected",
|
||||||
|
[
|
||||||
|
(20, 10, 10),
|
||||||
|
(1000, 1000, 1000),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_per_page_bounds(per_page, max_per_page, expected):
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
cfg.Pagination.max_per_page = 10
|
cfg.Pagination.max_per_page = max_per_page
|
||||||
p = Pagination(config=cfg, per_page=20, total=100)
|
p = Pagination(config=cfg)
|
||||||
assert p.per_page == 10
|
p.per_page = per_page
|
||||||
|
p.total = 99999
|
||||||
|
assert p.per_page == expected
|
||||||
with raises(Exception):
|
with raises(Exception):
|
||||||
p.per_page = 0
|
p.per_page = 0
|
||||||
|
|
||||||
@@ -39,7 +48,5 @@ def test_per_page_bounds():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_window(page, per_page, total, expected):
|
def test_window(page, per_page, total, expected):
|
||||||
cfg = Config()
|
|
||||||
cfg.Pagination
|
|
||||||
pagination = Pagination(page=page, per_page=per_page, total=total)
|
pagination = Pagination(page=page, per_page=per_page, total=total)
|
||||||
assert pagination.calculate_pages_window() == expected
|
assert pagination.calculate_pages_window() == expected
|
||||||
|
@@ -1,19 +1,23 @@
|
|||||||
"""Test scopes for API handlers"""
|
"""Test scopes for API handlers"""
|
||||||
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import tornado
|
|
||||||
from pytest import mark
|
from pytest import mark
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from tornado.httputil import HTTPServerRequest
|
from tornado.httputil import HTTPServerRequest
|
||||||
|
|
||||||
from .. import orm
|
from .. import orm
|
||||||
from ..utils import check_scope
|
from .. import roles
|
||||||
from ..utils import needs_scope
|
from ..scopes import _check_scope
|
||||||
from ..utils import parse_scopes
|
from ..scopes import _parse_scopes
|
||||||
from ..utils import Scope
|
from ..scopes import needs_scope
|
||||||
|
from ..scopes import Scope
|
||||||
|
from .mocking import MockHub
|
||||||
from .utils import add_user
|
from .utils import add_user
|
||||||
from .utils import api_request
|
from .utils import api_request
|
||||||
|
from .utils import auth_header
|
||||||
|
from .utils import public_url
|
||||||
|
|
||||||
|
|
||||||
def test_scope_constructor():
|
def test_scope_constructor():
|
||||||
@@ -24,7 +28,7 @@ def test_scope_constructor():
|
|||||||
'read:users!user={}'.format(user1),
|
'read:users!user={}'.format(user1),
|
||||||
'read:users!user={}'.format(user2),
|
'read:users!user={}'.format(user2),
|
||||||
]
|
]
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
|
|
||||||
assert 'read:users' in parsed_scopes
|
assert 'read:users' in parsed_scopes
|
||||||
assert parsed_scopes['users']
|
assert parsed_scopes['users']
|
||||||
@@ -33,57 +37,60 @@ def test_scope_constructor():
|
|||||||
|
|
||||||
def test_scope_precendence():
|
def test_scope_precendence():
|
||||||
scope_list = ['read:users!user=maeby', 'read:users']
|
scope_list = ['read:users!user=maeby', 'read:users']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert parsed_scopes['read:users'] == Scope.ALL
|
assert parsed_scopes['read:users'] == Scope.ALL
|
||||||
|
|
||||||
|
|
||||||
def test_scope_check_present():
|
def test_scope_check_present():
|
||||||
handler = None
|
handler = None
|
||||||
scope_list = ['read:users']
|
scope_list = ['read:users']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert check_scope(handler, 'read:users', parsed_scopes)
|
assert _check_scope(handler, 'read:users', parsed_scopes)
|
||||||
assert check_scope(handler, 'read:users', parsed_scopes, user='maeby')
|
assert _check_scope(handler, 'read:users', parsed_scopes, user='maeby')
|
||||||
|
|
||||||
|
|
||||||
def test_scope_check_not_present():
|
def test_scope_check_not_present():
|
||||||
handler = None
|
handler = None
|
||||||
scope_list = ['read:users!user=maeby']
|
scope_list = ['read:users!user=maeby']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert not check_scope(handler, 'read:users', parsed_scopes)
|
assert not _check_scope(handler, 'read:users', parsed_scopes)
|
||||||
assert not check_scope(handler, 'read:users', parsed_scopes, user='gob')
|
with pytest.raises(web.HTTPError):
|
||||||
assert not check_scope(
|
_check_scope(handler, 'read:users', parsed_scopes, user='gob')
|
||||||
handler, 'read:users', parsed_scopes, user='gob', server='server'
|
with pytest.raises(web.HTTPError):
|
||||||
)
|
_check_scope(handler, 'read:users', parsed_scopes, user='gob', server='server')
|
||||||
|
|
||||||
|
|
||||||
def test_scope_filters():
|
def test_scope_filters():
|
||||||
handler = None
|
handler = None
|
||||||
scope_list = ['read:users', 'read:users!group=bluths', 'read:users!user=maeby']
|
scope_list = ['read:users', 'read:users!group=bluths', 'read:users!user=maeby']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert check_scope(handler, 'read:users', parsed_scopes, group='bluth')
|
assert _check_scope(handler, 'read:users', parsed_scopes, group='bluth')
|
||||||
assert check_scope(handler, 'read:users', parsed_scopes, user='maeby')
|
assert _check_scope(handler, 'read:users', parsed_scopes, user='maeby')
|
||||||
|
|
||||||
|
|
||||||
def test_scope_one_filter_only():
|
def test_scope_multiple_filters():
|
||||||
handler = None
|
handler = None
|
||||||
with pytest.raises(AttributeError):
|
assert _check_scope(
|
||||||
check_scope(
|
handler,
|
||||||
handler, 'all', parse_scopes(['all']), user='george_michael', group='bluths'
|
'read:users',
|
||||||
)
|
_parse_scopes(['read:users!user=george_michael']),
|
||||||
|
user='george_michael',
|
||||||
|
group='bluths',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_scope_parse_server_name():
|
def test_scope_parse_server_name():
|
||||||
handler = None
|
handler = None
|
||||||
scope_list = ['users:servers!server=maeby/server1', 'read:users!user=maeby']
|
scope_list = ['users:servers!server=maeby/server1', 'read:users!user=maeby']
|
||||||
parsed_scopes = parse_scopes(scope_list)
|
parsed_scopes = _parse_scopes(scope_list)
|
||||||
assert check_scope(
|
assert _check_scope(
|
||||||
handler, 'users:servers', parsed_scopes, user='maeby', server='server1'
|
handler, 'users:servers', parsed_scopes, user='maeby', server='server1'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MockAPIHandler:
|
class MockAPIHandler:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.scopes = ['users']
|
self.scopes = {'users'}
|
||||||
|
|
||||||
@needs_scope('users')
|
@needs_scope('users')
|
||||||
def user_thing(self, user_name):
|
def user_thing(self, user_name):
|
||||||
@@ -97,7 +104,7 @@ class MockAPIHandler:
|
|||||||
def group_thing(self, group_name):
|
def group_thing(self, group_name):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@needs_scope('services')
|
@needs_scope('read:services')
|
||||||
def service_thing(self, service_name):
|
def service_thing(self, service_name):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -105,6 +112,11 @@ class MockAPIHandler:
|
|||||||
def other_thing(self, other_name):
|
def other_thing(self, other_name):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@needs_scope('users')
|
||||||
|
@needs_scope('read:services')
|
||||||
|
def secret_thing(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
"scopes, method, arguments, is_allowed",
|
"scopes, method, arguments, is_allowed",
|
||||||
@@ -115,6 +127,7 @@ class MockAPIHandler:
|
|||||||
(['read:users'], 'user_thing', ('gob',), False),
|
(['read:users'], 'user_thing', ('gob',), False),
|
||||||
(['read:users'], 'user_thing', ('michael',), False),
|
(['read:users'], 'user_thing', ('michael',), False),
|
||||||
(['users!user=george'], 'user_thing', ('george',), True),
|
(['users!user=george'], 'user_thing', ('george',), True),
|
||||||
|
(['users!user=george'], 'user_thing', ('fake_user',), False),
|
||||||
(['users!user=george'], 'user_thing', ('oscar',), False),
|
(['users!user=george'], 'user_thing', ('oscar',), False),
|
||||||
(['users!user=george', 'users!user=oscar'], 'user_thing', ('oscar',), True),
|
(['users!user=george', 'users!user=oscar'], 'user_thing', ('oscar',), True),
|
||||||
(['users:servers'], 'server_thing', ('user1', 'server_1'), True),
|
(['users:servers'], 'server_thing', ('user1', 'server_1'), True),
|
||||||
@@ -133,7 +146,7 @@ class MockAPIHandler:
|
|||||||
('maybe', 'bluth2'),
|
('maybe', 'bluth2'),
|
||||||
False,
|
False,
|
||||||
),
|
),
|
||||||
(['services'], 'service_thing', ('service1',), True),
|
(['read:services'], 'service_thing', ('service1',), True),
|
||||||
(
|
(
|
||||||
['users!user=george', 'read:groups!group=bluths'],
|
['users!user=george', 'read:groups!group=bluths'],
|
||||||
'group_thing',
|
'group_thing',
|
||||||
@@ -160,8 +173,9 @@ class MockAPIHandler:
|
|||||||
)
|
)
|
||||||
def test_scope_method_access(scopes, method, arguments, is_allowed):
|
def test_scope_method_access(scopes, method, arguments, is_allowed):
|
||||||
obj = MockAPIHandler()
|
obj = MockAPIHandler()
|
||||||
|
obj.current_user = mock.Mock(name=arguments[0])
|
||||||
obj.request = mock.Mock(spec=HTTPServerRequest)
|
obj.request = mock.Mock(spec=HTTPServerRequest)
|
||||||
obj.scopes = scopes
|
obj.scopes = set(scopes)
|
||||||
api_call = getattr(obj, method)
|
api_call = getattr(obj, method)
|
||||||
if is_allowed:
|
if is_allowed:
|
||||||
assert api_call(*arguments)
|
assert api_call(*arguments)
|
||||||
@@ -170,17 +184,55 @@ def test_scope_method_access(scopes, method, arguments, is_allowed):
|
|||||||
api_call(*arguments)
|
api_call(*arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_scoped_method_succeeds():
|
||||||
|
obj = MockAPIHandler()
|
||||||
|
obj.current_user = mock.Mock(name='lucille')
|
||||||
|
obj.request = mock.Mock(spec=HTTPServerRequest)
|
||||||
|
obj.scopes = {'users', 'read:services'}
|
||||||
|
assert obj.secret_thing()
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_scoped_method_denials():
|
||||||
|
obj = MockAPIHandler()
|
||||||
|
obj.current_user = mock.Mock(name='lucille2')
|
||||||
|
obj.request = mock.Mock(spec=HTTPServerRequest)
|
||||||
|
obj.scopes = {'users', 'read:groups'}
|
||||||
|
with pytest.raises(web.HTTPError):
|
||||||
|
obj.secret_thing()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_test_role(user_name, scopes, role_name='test'):
|
||||||
|
role = {
|
||||||
|
'name': role_name,
|
||||||
|
'description': '',
|
||||||
|
'users': [user_name],
|
||||||
|
'scopes': scopes,
|
||||||
|
}
|
||||||
|
return role
|
||||||
|
|
||||||
|
|
||||||
@mark.parametrize(
|
@mark.parametrize(
|
||||||
"user_name, in_group, status_code",
|
"user_name, in_group, status_code",
|
||||||
[
|
[
|
||||||
('martha', False, 200),
|
('martha', False, 200),
|
||||||
('michael', True, 200),
|
('michael', True, 200),
|
||||||
('gob', True, 200),
|
('gob', True, 200),
|
||||||
('tobias', False, 403),
|
('tobias', False, 404),
|
||||||
('ann', False, 403),
|
('ann', False, 404),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_expand_groups(app, user_name, in_group, status_code):
|
async def test_expand_groups(app, user_name, in_group, status_code):
|
||||||
|
test_role = {
|
||||||
|
'name': 'test',
|
||||||
|
'description': '',
|
||||||
|
'users': [user_name],
|
||||||
|
'scopes': [
|
||||||
|
'read:users!user=martha',
|
||||||
|
'read:users!group=bluth',
|
||||||
|
'read:groups',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
roles.add_role(app.db, test_role)
|
||||||
user = add_user(app.db, name=user_name)
|
user = add_user(app.db, name=user_name)
|
||||||
group_name = 'bluth'
|
group_name = 'bluth'
|
||||||
group = orm.Group.find(app.db, name=group_name)
|
group = orm.Group.find(app.db, name=group_name)
|
||||||
@@ -189,11 +241,156 @@ async def test_expand_groups(app, user_name, in_group, status_code):
|
|||||||
app.db.add(group)
|
app.db.add(group)
|
||||||
if in_group and user not in group.users:
|
if in_group and user not in group.users:
|
||||||
group.users.append(user)
|
group.users.append(user)
|
||||||
|
kind = 'users'
|
||||||
|
roles.update_roles(app.db, user, kind, roles=['test'])
|
||||||
|
roles.remove_obj(app.db, user_name, kind, 'user')
|
||||||
app.db.commit()
|
app.db.commit()
|
||||||
app.tornado_settings['mock_scopes'] = [
|
r = await api_request(
|
||||||
'read:users!user=martha',
|
app, 'users', user_name, headers=auth_header(app.db, user_name)
|
||||||
'read:users!group=bluth',
|
)
|
||||||
'read:groups',
|
|
||||||
]
|
|
||||||
r = await api_request(app, 'users', user_name)
|
|
||||||
assert r.status_code == status_code
|
assert r.status_code == status_code
|
||||||
|
|
||||||
|
|
||||||
|
async def test_by_fake_user(app):
|
||||||
|
user_name = 'shade'
|
||||||
|
user = add_user(app.db, name=user_name)
|
||||||
|
auth_ = auth_header(app.db, user_name)
|
||||||
|
app.users.delete(user)
|
||||||
|
app.db.commit()
|
||||||
|
r = await api_request(app, 'users', headers=auth_)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
err_message = "No access to resources or resources not found"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_fake_user(app):
|
||||||
|
user_name = 'buster'
|
||||||
|
fake_user = 'annyong'
|
||||||
|
add_user(app.db, name=user_name)
|
||||||
|
test_role = generate_test_role(user_name, ['read:users!group=stuff'])
|
||||||
|
roles.add_role(app.db, test_role)
|
||||||
|
roles.add_obj(app.db, objname=user_name, kind='users', rolename='test')
|
||||||
|
app.db.commit()
|
||||||
|
r = await api_request(
|
||||||
|
app, 'users', fake_user, headers=auth_header(app.db, user_name)
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
# Consistency between no user and user not accessible
|
||||||
|
assert r.json()['message'] == err_message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_user_outside_group(app):
|
||||||
|
user_name = 'buster'
|
||||||
|
fake_user = 'hello'
|
||||||
|
add_user(app.db, name=user_name)
|
||||||
|
add_user(app.db, name=fake_user)
|
||||||
|
test_role = generate_test_role(user_name, ['read:users!group=stuff'])
|
||||||
|
roles.add_role(app.db, test_role)
|
||||||
|
roles.add_obj(app.db, objname=user_name, kind='users', rolename='test')
|
||||||
|
roles.remove_obj(app.db, objname=user_name, kind='users', rolename='user')
|
||||||
|
app.db.commit()
|
||||||
|
print(orm.User.find(db=app.db, name=user_name).roles)
|
||||||
|
r = await api_request(
|
||||||
|
app, 'users', fake_user, headers=auth_header(app.db, user_name)
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
# Consistency between no user and user not accessible
|
||||||
|
assert r.json()['message'] == err_message
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_filter(app):
|
||||||
|
user_name = 'rita'
|
||||||
|
user = add_user(app.db, name=user_name)
|
||||||
|
app.db.commit()
|
||||||
|
scopes = ['read:users!user=lindsay', 'read:users!user=gob', 'read:users!user=oscar']
|
||||||
|
test_role = generate_test_role(user, scopes)
|
||||||
|
roles.add_role(app.db, test_role)
|
||||||
|
roles.add_obj(app.db, objname=user_name, kind='users', rolename='test')
|
||||||
|
roles.remove_obj(app.db, objname=user_name, kind='users', rolename='user')
|
||||||
|
name_in_scope = {'lindsay', 'oscar', 'gob'}
|
||||||
|
outside_scope = {'maeby', 'marta'}
|
||||||
|
group_name = 'bluth'
|
||||||
|
group = orm.Group.find(app.db, name=group_name)
|
||||||
|
if not group:
|
||||||
|
group = orm.Group(name=group_name)
|
||||||
|
app.db.add(group)
|
||||||
|
for name in name_in_scope | outside_scope:
|
||||||
|
user = add_user(app.db, name=name)
|
||||||
|
if name not in group.users:
|
||||||
|
group.users.append(user)
|
||||||
|
app.db.commit()
|
||||||
|
r = await api_request(app, 'users', headers=auth_header(app.db, user_name))
|
||||||
|
assert r.status_code == 200
|
||||||
|
result_names = {user['name'] for user in r.json()}
|
||||||
|
assert result_names == name_in_scope
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_filter(app):
|
||||||
|
services = [
|
||||||
|
{'name': 'cull_idle', 'api_token': 'some-token'},
|
||||||
|
{'name': 'user_service', 'api_token': 'some-other-token'},
|
||||||
|
]
|
||||||
|
for service in services:
|
||||||
|
app.services.append(service)
|
||||||
|
app.init_services()
|
||||||
|
user_name = 'buster'
|
||||||
|
user = add_user(app.db, name=user_name)
|
||||||
|
app.db.commit()
|
||||||
|
test_role = generate_test_role(user, ['read:services!service=cull_idle'])
|
||||||
|
roles.add_role(app.db, test_role)
|
||||||
|
roles.add_obj(app.db, objname=user_name, kind='users', rolename='test')
|
||||||
|
r = await api_request(app, 'services', headers=auth_header(app.db, user_name))
|
||||||
|
assert r.status_code == 200
|
||||||
|
service_names = set(r.json().keys())
|
||||||
|
assert service_names == {'cull_idle'}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_filter_with_group(app):
|
||||||
|
# Move role setup to setup method?
|
||||||
|
user_name = 'sally'
|
||||||
|
add_user(app.db, name=user_name)
|
||||||
|
external_user_name = 'britta'
|
||||||
|
add_user(app.db, name=external_user_name)
|
||||||
|
test_role = generate_test_role(user_name, ['read:users!group=sitwell'])
|
||||||
|
roles.add_role(app.db, test_role)
|
||||||
|
roles.add_obj(app.db, objname=user_name, kind='users', rolename='test')
|
||||||
|
|
||||||
|
name_set = {'sally', 'stan'}
|
||||||
|
group_name = 'sitwell'
|
||||||
|
group = orm.Group.find(app.db, name=group_name)
|
||||||
|
if not group:
|
||||||
|
group = orm.Group(name=group_name)
|
||||||
|
app.db.add(group)
|
||||||
|
for name in name_set:
|
||||||
|
user = add_user(app.db, name=name)
|
||||||
|
if name not in group.users:
|
||||||
|
group.users.append(user)
|
||||||
|
app.db.commit()
|
||||||
|
|
||||||
|
r = await api_request(app, 'users', headers=auth_header(app.db, user_name))
|
||||||
|
assert r.status_code == 200
|
||||||
|
result_names = {user['name'] for user in r.json()}
|
||||||
|
assert result_names == name_set
|
||||||
|
assert external_user_name not in result_names
|
||||||
|
|
||||||
|
|
||||||
|
async def test_group_scope_filter(app):
|
||||||
|
user_name = 'rollerblade'
|
||||||
|
add_user(app.db, name=user_name)
|
||||||
|
scopes = ['read:groups!group=sitwell', 'read:groups!group=bluth']
|
||||||
|
test_role = generate_test_role(user_name, scopes)
|
||||||
|
roles.add_role(app.db, test_role)
|
||||||
|
roles.add_obj(app.db, objname=user_name, kind='users', rolename='test')
|
||||||
|
|
||||||
|
group_set = {'sitwell', 'bluth', 'austero'}
|
||||||
|
for group_name in group_set:
|
||||||
|
group = orm.Group.find(app.db, name=group_name)
|
||||||
|
if not group:
|
||||||
|
group = orm.Group(name=group_name)
|
||||||
|
app.db.add(group)
|
||||||
|
app.db.commit()
|
||||||
|
r = await api_request(app, 'groups', headers=auth_header(app.db, user_name))
|
||||||
|
assert r.status_code == 200
|
||||||
|
result_names = {user['name'] for user in r.json()}
|
||||||
|
assert result_names == {'sitwell', 'bluth'}
|
||||||
|
@@ -88,6 +88,7 @@ async def test_external_service(app):
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
await maybe_future(app.init_services())
|
await maybe_future(app.init_services())
|
||||||
|
await maybe_future(app.init_roles())
|
||||||
await app.init_api_tokens()
|
await app.init_api_tokens()
|
||||||
await app.proxy.add_all_services(app._service_map)
|
await app.proxy.add_all_services(app._service_map)
|
||||||
await app.init_roles()
|
await app.init_roles()
|
||||||
|
@@ -124,7 +124,7 @@ def auth_header(db, name):
|
|||||||
"""Return header with user's API authorization token."""
|
"""Return header with user's API authorization token."""
|
||||||
user = find_user(db, name)
|
user = find_user(db, name)
|
||||||
if user is None:
|
if user is None:
|
||||||
user = add_user(db, name=name)
|
raise KeyError(f"No such user: {name}")
|
||||||
token = user.new_api_token()
|
token = user.new_api_token()
|
||||||
return {'Authorization': 'token %s' % token}
|
return {'Authorization': 'token %s' % token}
|
||||||
|
|
||||||
@@ -226,6 +226,7 @@ def get_scopes(role='admin'):
|
|||||||
'services',
|
'services',
|
||||||
'proxy',
|
'proxy',
|
||||||
'shutdown',
|
'shutdown',
|
||||||
|
'read:hub',
|
||||||
],
|
],
|
||||||
'user': [
|
'user': [
|
||||||
'all',
|
'all',
|
||||||
|
@@ -4,7 +4,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import errno
|
import errno
|
||||||
import functools
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
@@ -18,7 +17,6 @@ import warnings
|
|||||||
from binascii import b2a_hex
|
from binascii import b2a_hex
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from enum import Enum
|
|
||||||
from hmac import compare_digest
|
from hmac import compare_digest
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
|
|
||||||
@@ -30,6 +28,15 @@ from tornado.httpclient import HTTPError
|
|||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
from tornado.platform.asyncio import to_asyncio_future
|
from tornado.platform.asyncio import to_asyncio_future
|
||||||
|
|
||||||
|
# For compatibility with python versions 3.6 or earlier.
|
||||||
|
# asyncio.Task.all_tasks() is fully moved to asyncio.all_tasks() starting with 3.9. Also applies to current_task.
|
||||||
|
try:
|
||||||
|
asyncio_all_tasks = asyncio.all_tasks
|
||||||
|
asyncio_current_task = asyncio.current_task
|
||||||
|
except AttributeError as e:
|
||||||
|
asyncio_all_tasks = asyncio.Task.all_tasks
|
||||||
|
asyncio_current_task = asyncio.Task.current_task
|
||||||
|
|
||||||
|
|
||||||
def random_port():
|
def random_port():
|
||||||
"""Get a single random port."""
|
"""Get a single random port."""
|
||||||
@@ -281,8 +288,13 @@ def authenticated_403(self):
|
|||||||
|
|
||||||
@auth_decorator
|
@auth_decorator
|
||||||
def admin_only(self):
|
def admin_only(self):
|
||||||
"""Decorator for restricting access to admin users"""
|
"""Decorator for restricting access to admin users
|
||||||
|
Deprecated in favor of scopes.need_scope()
|
||||||
|
"""
|
||||||
user = self.current_user
|
user = self.current_user
|
||||||
|
app_log.warning(
|
||||||
|
"Admin decorator is deprecated and will be removed soon. Use scope-based decorator instead"
|
||||||
|
)
|
||||||
if user is None or not user.admin:
|
if user is None or not user.admin:
|
||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
@@ -295,140 +307,6 @@ def metrics_authentication(self):
|
|||||||
raise web.HTTPError(403)
|
raise web.HTTPError(403)
|
||||||
|
|
||||||
|
|
||||||
class Scope(Enum):
|
|
||||||
ALL = True
|
|
||||||
|
|
||||||
|
|
||||||
def needs_scope_expansion(filter_, filter_value, sub_scope):
|
|
||||||
"""
|
|
||||||
Check if there is a requirements to expand the `group` scope to individual `user` scopes.
|
|
||||||
Assumptions:
|
|
||||||
filter_ != Scope.ALL
|
|
||||||
|
|
||||||
This can be made arbitrarily intelligent but that would make it more complex
|
|
||||||
"""
|
|
||||||
if not (filter_ == 'user' and 'group' in sub_scope):
|
|
||||||
return False
|
|
||||||
if 'user' in sub_scope:
|
|
||||||
return filter_value not in sub_scope['user']
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def check_user_in_expanded_scope(handler, user_name, scope_group_names):
|
|
||||||
user = handler.find_user(user_name)
|
|
||||||
if user is None:
|
|
||||||
raise web.HTTPError(404, 'No such user found')
|
|
||||||
group_names = {group.name for group in user.groups}
|
|
||||||
return bool(set(scope_group_names) & group_names)
|
|
||||||
|
|
||||||
|
|
||||||
def check_scope(api_handler, req_scope, scopes, **kwargs):
|
|
||||||
# Parse user name and server name together
|
|
||||||
if 'user' in kwargs and 'server' in kwargs:
|
|
||||||
user_name = kwargs.pop('user')
|
|
||||||
kwargs['server'] = "{}/{}".format(user_name, kwargs['server'])
|
|
||||||
if len(kwargs) > 1:
|
|
||||||
raise AttributeError("Please specify exactly one filter")
|
|
||||||
if req_scope not in scopes:
|
|
||||||
return False
|
|
||||||
if scopes[req_scope] == Scope.ALL:
|
|
||||||
return True
|
|
||||||
# Apply filters
|
|
||||||
if not kwargs:
|
|
||||||
return False
|
|
||||||
filter_, filter_value = list(kwargs.items())[0]
|
|
||||||
sub_scope = scopes[req_scope]
|
|
||||||
if filter_ not in sub_scope:
|
|
||||||
valid_scope = False
|
|
||||||
else:
|
|
||||||
valid_scope = filter_value in sub_scope[filter_]
|
|
||||||
if not valid_scope and needs_scope_expansion(filter_, filter_value, sub_scope):
|
|
||||||
group_names = sub_scope['group']
|
|
||||||
valid_scope |= check_user_in_expanded_scope(
|
|
||||||
api_handler, filter_value, group_names
|
|
||||||
)
|
|
||||||
return valid_scope
|
|
||||||
|
|
||||||
|
|
||||||
def parse_scopes(scope_list):
|
|
||||||
"""
|
|
||||||
Parses scopes and filters in something akin to JSON style
|
|
||||||
|
|
||||||
For instance, scope list ["users", "groups!group=foo", "users:servers!server=bar", "users:servers!server=baz"]
|
|
||||||
would lead to scope model
|
|
||||||
{
|
|
||||||
"users":scope.ALL,
|
|
||||||
"users:admin":{
|
|
||||||
"user":[
|
|
||||||
"alice"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"users:servers":{
|
|
||||||
"server":[
|
|
||||||
"bar",
|
|
||||||
"baz"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
parsed_scopes = {}
|
|
||||||
for scope in scope_list:
|
|
||||||
base_scope, _, filter_ = scope.partition('!')
|
|
||||||
if not filter_:
|
|
||||||
parsed_scopes[base_scope] = Scope.ALL
|
|
||||||
elif base_scope not in parsed_scopes:
|
|
||||||
parsed_scopes[base_scope] = {}
|
|
||||||
if parsed_scopes[base_scope] != Scope.ALL:
|
|
||||||
key, _, val = filter_.partition('=')
|
|
||||||
if key not in parsed_scopes[base_scope]:
|
|
||||||
parsed_scopes[base_scope][key] = []
|
|
||||||
parsed_scopes[base_scope][key].append(val)
|
|
||||||
return parsed_scopes
|
|
||||||
|
|
||||||
|
|
||||||
def needs_scope(scope):
|
|
||||||
"""Decorator to restrict access to users or services with the required scope"""
|
|
||||||
|
|
||||||
def scope_decorator(func):
|
|
||||||
@functools.wraps(func)
|
|
||||||
def _auth_func(self, *args, **kwargs):
|
|
||||||
sig = inspect.signature(func)
|
|
||||||
bound_sig = sig.bind(self, *args, **kwargs)
|
|
||||||
bound_sig.apply_defaults()
|
|
||||||
s_kwargs = {}
|
|
||||||
for resource in {'user', 'server', 'group', 'service'}:
|
|
||||||
resource_name = resource + '_name'
|
|
||||||
if resource_name in bound_sig.arguments:
|
|
||||||
resource_value = bound_sig.arguments[resource_name]
|
|
||||||
s_kwargs[resource] = resource_value
|
|
||||||
parsed_scopes = parse_scopes(self.scopes)
|
|
||||||
if check_scope(self, scope, parsed_scopes, **s_kwargs):
|
|
||||||
return func(self, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
# catching attr error occurring for older_requirements test
|
|
||||||
# could be done more ellegantly?
|
|
||||||
try:
|
|
||||||
request_path = self.request.path
|
|
||||||
except AttributeError:
|
|
||||||
request_path = 'the requested API'
|
|
||||||
app_log.warning(
|
|
||||||
"Not authorizing access to {}. Requires scope {}, not derived from scopes {}".format(
|
|
||||||
request_path, scope, ", ".join(self.scopes)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise web.HTTPError(
|
|
||||||
403,
|
|
||||||
"Action is not authorized with current scopes; requires {}".format(
|
|
||||||
scope
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return _auth_func
|
|
||||||
|
|
||||||
return scope_decorator
|
|
||||||
|
|
||||||
|
|
||||||
# Token utilities
|
# Token utilities
|
||||||
|
|
||||||
|
|
||||||
@@ -611,7 +489,7 @@ def print_stacks(file=sys.stderr):
|
|||||||
# also show asyncio tasks, if any
|
# also show asyncio tasks, if any
|
||||||
# this will increase over time as we transition from tornado
|
# this will increase over time as we transition from tornado
|
||||||
# coroutines to native `async def`
|
# coroutines to native `async def`
|
||||||
tasks = asyncio.Task.all_tasks()
|
tasks = asyncio_all_tasks()
|
||||||
if tasks:
|
if tasks:
|
||||||
print("AsyncIO tasks: %i" % len(tasks))
|
print("AsyncIO tasks: %i" % len(tasks))
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
alembic
|
alembic>=1.4
|
||||||
async_generator>=1.9
|
async_generator>=1.9
|
||||||
certipy>=0.1.2
|
certipy>=0.1.2
|
||||||
entrypoints
|
entrypoints
|
||||||
jinja2
|
jinja2>=2.11.0
|
||||||
jupyter_telemetry>=0.1.0
|
jupyter_telemetry>=0.1.0
|
||||||
oauthlib>=3.0
|
oauthlib>=3.0
|
||||||
pamela; sys_platform != 'win32'
|
pamela; sys_platform != 'win32'
|
||||||
|
@@ -68,6 +68,18 @@
|
|||||||
<i class="fa fa-spinner"></i>
|
<i class="fa fa-spinner"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% block login_terms %}
|
||||||
|
{% if login_term_url %}
|
||||||
|
<div id="login_terms" class="login_terms">
|
||||||
|
<input type="checkbox" id="login_terms_checkbox" name="login_terms_checkbox" required />
|
||||||
|
{% block login_terms_text %} {# allow overriding the text #}
|
||||||
|
By logging into the platform you accept the <a href="{{ login_term_url }}">terms and conditions</a>.
|
||||||
|
{% endblock login_terms_text %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock login_terms %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
Reference in New Issue
Block a user