resolved conflicts with rbac branch

This commit is contained in:
IvanaH8
2021-02-17 16:31:46 +01:00
36 changed files with 900 additions and 396 deletions

61
.github/workflows/release.yml vendored Normal file
View 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/*

View File

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

View File

@@ -13,7 +13,7 @@
[![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub?logo=pypi)](https://pypi.python.org/pypi/jupyterhub) [![Latest PyPI version](https://img.shields.io/pypi/v/jupyterhub?logo=pypi)](https://pypi.python.org/pypi/jupyterhub)
[![Latest conda-forge version](https://img.shields.io/conda/vn/conda-forge/jupyterhub?logo=conda-forge)](https://www.npmjs.com/package/jupyterhub) [![Latest conda-forge version](https://img.shields.io/conda/vn/conda-forge/jupyterhub?logo=conda-forge)](https://www.npmjs.com/package/jupyterhub)
[![Documentation build status](https://img.shields.io/readthedocs/jupyterhub?logo=read-the-docs)](https://jupyterhub.readthedocs.org/en/latest/) [![Documentation build status](https://img.shields.io/readthedocs/jupyterhub?logo=read-the-docs)](https://jupyterhub.readthedocs.org/en/latest/)
[![TravisCI build status](https://img.shields.io/travis/com/jupyterhub/jupyterhub?logo=travis)](https://travis-ci.com/jupyterhub/jupyterhub) [![GitHub Workflow Status - Test](https://img.shields.io/github/workflow/status/jupyterhub/jupyterhub/Test?logo=github&label=tests)](https://github.com/jupyterhub/jupyterhub/actions)
[![DockerHub build status](https://img.shields.io/docker/build/jupyterhub/jupyterhub?logo=docker&label=build)](https://hub.docker.com/r/jupyterhub/jupyterhub/tags) [![DockerHub build status](https://img.shields.io/docker/build/jupyterhub/jupyterhub?logo=docker&label=build)](https://hub.docker.com/r/jupyterhub/jupyterhub/tags)
[![CircleCI build status](https://img.shields.io/circleci/build/github/jupyterhub/jupyterhub?logo=circleci)](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc --> [![CircleCI build status](https://img.shields.io/circleci/build/github/jupyterhub/jupyterhub?logo=circleci)](https://circleci.com/gh/jupyterhub/jupyterhub)<!-- CircleCI Token: b5b65862eb2617b9a8d39e79340b0a6b816da8cc -->
[![Test coverage of code](https://codecov.io/gh/jupyterhub/jupyterhub/branch/master/graph/badge.svg)](https://codecov.io/gh/jupyterhub/jupyterhub) [![Test coverage of code](https://codecov.io/gh/jupyterhub/jupyterhub/branch/master/graph/badge.svg)](https://codecov.io/gh/jupyterhub/jupyterhub)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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